Compare commits
33 Commits
feat-highl
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7bfe0a3e | ||
|
|
3348e11796 | ||
|
|
ad6a3dbbab | ||
|
|
bb74308e28 | ||
|
|
0c6de715c4 | ||
|
|
13b3b82443 | ||
|
|
e60a460480 | ||
|
|
81667112d1 | ||
|
|
c60d7ab401 | ||
|
|
e25902b8b4 | ||
|
|
d964c7b7b3 | ||
|
|
25b2831fcc | ||
|
|
1553227e13 | ||
|
|
f04981f5b9 | ||
|
|
2662373704 | ||
|
|
526b64aec0 | ||
|
|
41a65338b5 | ||
|
|
56f0aa9fd5 | ||
|
|
89f79b999c | ||
|
|
7459a3d33a | ||
|
|
49eca495f5 | ||
|
|
96abe5f24f | ||
|
|
0ee93718da | ||
|
|
a880a92748 | ||
|
|
cd7c52eda0 | ||
|
|
ef6d44d112 | ||
|
|
2925c0c5f9 | ||
|
|
5705d8c9b3 | ||
|
|
944246b582 | ||
|
|
163f3212d8 | ||
|
|
1193c81c78 | ||
|
|
ddb88bf074 | ||
|
|
079a2f90ef |
12
AGENTS.md
@@ -1,12 +1,12 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This document is designed to help AI Agents better understand and modify the Jumble project.
|
This document is designed to help AI Agents better understand and modify the Smesh project.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Jumble is a user-friendly Nostr client for exploring relay feeds.
|
Smesh is a user-friendly Nostr client for exploring relay feeds.
|
||||||
|
|
||||||
- **Project Name**: Jumble
|
- **Project Name**: Smesh
|
||||||
- **Main Tech Stack**: React 18 + TypeScript + Vite
|
- **Main Tech Stack**: React 18 + TypeScript + Vite
|
||||||
- **UI Framework**: Tailwind CSS + Radix UI
|
- **UI Framework**: Tailwind CSS + Radix UI
|
||||||
- **State Management**: Jotai
|
- **State Management**: Jotai
|
||||||
@@ -37,7 +37,7 @@ Jumble is a user-friendly Nostr client for exploring relay feeds.
|
|||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
jumble/
|
smesh/
|
||||||
тФЬтФАтФА src/
|
тФЬтФАтФА src/
|
||||||
тФВ тФЬтФАтФА components/ # React components
|
тФВ тФЬтФАтФА components/ # React components
|
||||||
тФВ тФВ тФЬтФАтФА ui/ # Base UI components (shadcn/ui style)
|
тФВ тФВ тФЬтФАтФА ui/ # Base UI components (shadcn/ui style)
|
||||||
@@ -147,11 +147,11 @@ And some Providers are placed in `PageManager.tsx` because they need to use the
|
|||||||
|
|
||||||
### Internationalization (i18n)
|
### 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.
|
Smesh 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/`
|
- Translation files located in `src/i18n/locales/`
|
||||||
- Using `react-i18next` for internationalization
|
- 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
|
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW
|
||||||
|
|
||||||
#### Adding New Language
|
#### Adding New Language
|
||||||
|
|
||||||
|
|||||||
786
DDD_ANALYSIS.md
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
# Domain-Driven Design Analysis: Smesh Nostr Client
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document provides a Domain-Driven Design (DDD) analysis of the Smesh codebase, a React/TypeScript Nostr client. The analysis identifies the implicit domain model, evaluates current architecture against DDD principles, and provides actionable recommendations for refactoring toward a more domain-centric design.
|
||||||
|
|
||||||
|
**Key Findings:**
|
||||||
|
- The codebase has implicit bounded contexts but lacks explicit boundaries
|
||||||
|
- Domain logic is scattered across providers, services, and lib utilities
|
||||||
|
- The architecture exhibits several DDD anti-patterns (Anemic Domain Model, Smart UI tendencies)
|
||||||
|
- Nostr events naturally align with Domain Events pattern
|
||||||
|
- Strong foundation exists for incremental DDD adoption
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Domain Analysis
|
||||||
|
|
||||||
|
### 1.1 Core Domain Identification
|
||||||
|
|
||||||
|
The Smesh application operates in the **decentralized social networking** domain, specifically implementing the Nostr protocol. The core business capabilities are:
|
||||||
|
|
||||||
|
| Subdomain | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| **Identity & Authentication** | Core | Key management, signing, account switching |
|
||||||
|
| **Social Graph** | Core | Following, muting, trust relationships |
|
||||||
|
| **Content Publishing** | Core | Notes, reactions, reposts, media |
|
||||||
|
| **Feed Curation** | Core | Timeline construction, filtering, relay selection |
|
||||||
|
| **Relay Management** | Supporting | Relay sets, discovery, connectivity |
|
||||||
|
| **Notifications** | Supporting | Real-time event monitoring |
|
||||||
|
| **Translation** | Generic | Multi-language content translation |
|
||||||
|
| **Media Upload** | Generic | NIP-96/Blossom file hosting |
|
||||||
|
|
||||||
|
### 1.2 Ubiquitous Language
|
||||||
|
|
||||||
|
The codebase uses Nostr protocol terminology, which forms the basis of the ubiquitous language:
|
||||||
|
|
||||||
|
| Term | Definition | Current Implementation |
|
||||||
|
|------|------------|----------------------|
|
||||||
|
| **Event** | Signed JSON object (note, reaction, etc.) | `nostr-tools` Event type |
|
||||||
|
| **Pubkey** | User's public key identifier | String (should be Value Object) |
|
||||||
|
| **Relay** | WebSocket server for event distribution | String URL (should be Value Object) |
|
||||||
|
| **Kind** | Event type identifier (0=profile, 1=note, etc.) | Number constants in `constants.ts` |
|
||||||
|
| **Tag** | Metadata attached to events (p, e, a tags) | String arrays (should be Value Objects) |
|
||||||
|
| **Profile** | User metadata (name, avatar, etc.) | `TProfile` type |
|
||||||
|
| **Follow List** | User's contact list (kind 3) | Array in `FollowListProvider` |
|
||||||
|
| **Mute List** | Blocked users/content (kind 10000) | Array in `MuteListProvider` |
|
||||||
|
| **Relay List** | User's preferred relays (kind 10002) | `TRelayList` type |
|
||||||
|
| **Signer** | Key management abstraction | `ISigner` interface |
|
||||||
|
|
||||||
|
**Language Issues Identified:**
|
||||||
|
- "Stuff Stats" is unclear domain terminology (rename to `InteractionMetrics`)
|
||||||
|
- "Favorite Relays" vs "Relay Sets" inconsistency
|
||||||
|
- "Draft Event" conflates unsigned events with work-in-progress content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current Architecture Assessment
|
||||||
|
|
||||||
|
### 2.1 Directory Structure Analysis
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
тФЬтФАтФА providers/ # State management + some domain logic (17 contexts)
|
||||||
|
тФЬтФАтФА services/ # Business logic + infrastructure concerns mixed
|
||||||
|
тФЬтФАтФА lib/ # Utility functions + domain logic mixed
|
||||||
|
тФЬтФАтФА types/ # Type definitions (implicit domain model)
|
||||||
|
тФЬтФАтФА components/ # UI components (some contain business logic)
|
||||||
|
тФЬтФАтФА pages/ # Page components
|
||||||
|
тФФтФАтФА hooks/ # Custom React hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** The architecture follows a layered approach but lacks explicit domain layer separation. Domain logic is distributed across:
|
||||||
|
- `lib/` - Event manipulation, validation
|
||||||
|
- `services/` - Data fetching, caching, persistence
|
||||||
|
- `providers/` - State management with embedded business rules
|
||||||
|
|
||||||
|
### 2.2 Implicit Bounded Contexts
|
||||||
|
|
||||||
|
The codebase contains several implicit bounded contexts that could be made explicit:
|
||||||
|
|
||||||
|
```
|
||||||
|
тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР
|
||||||
|
тФВ CONTEXT MAP тФВ
|
||||||
|
тФЬтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФд
|
||||||
|
тФВ тФВ
|
||||||
|
тФВ тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР Partnership тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР тФВ
|
||||||
|
тФВ тФВ Identity тФВтЧДтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтЦ║тФВ Social Graph тФВ тФВ
|
||||||
|
тФВ тФВ Context тФВ тФВ Context тФВ тФВ
|
||||||
|
тФВ тФФтФАтФАтФАтФАтФАтФАтФмтФАтФАтФАтФАтФАтФАтФАтФШ тФФтФАтФАтФАтФАтФАтФАтФмтФАтФАтФАтФАтФАтФАтФАтФШ тФВ
|
||||||
|
тФВ тФВ тФВ тФВ
|
||||||
|
тФВ тФВ Customer/Supplier тФВ тФВ
|
||||||
|
тФВ тЦ╝ тЦ╝ тФВ
|
||||||
|
тФВ тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР тФВ
|
||||||
|
тФВ тФВ Content тФВ тФВ Feed тФВ тФВ
|
||||||
|
тФВ тФВ Context тФВ тФВ Context тФВ тФВ
|
||||||
|
тФВ тФФтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФШ тФФтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФШ тФВ
|
||||||
|
тФВ тФВ тФВ тФВ
|
||||||
|
тФВ тФФтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФмтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФШ тФВ
|
||||||
|
тФВ тЦ╝ тФВ
|
||||||
|
тФВ тФМтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФР тФВ
|
||||||
|
тФВ тФВ Relay тФВ тФВ
|
||||||
|
тФВ тФВ Context тФВ тФВ
|
||||||
|
тФВ тФФтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФШ тФВ
|
||||||
|
тФВ тФВ
|
||||||
|
тФФтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФШ
|
||||||
|
```
|
||||||
|
|
||||||
|
**Context Descriptions:**
|
||||||
|
|
||||||
|
1. **Identity Context**
|
||||||
|
- Concerns: Key management, signing, account switching
|
||||||
|
- Current: `NostrProvider`, `ISigner` implementations
|
||||||
|
- Entities: Account, Signer
|
||||||
|
|
||||||
|
2. **Social Graph Context**
|
||||||
|
- Concerns: Following, muting, trust, pinned users
|
||||||
|
- Current: `FollowListProvider`, `MuteListProvider`, `UserTrustProvider`
|
||||||
|
- Entities: User, FollowList, MuteList
|
||||||
|
|
||||||
|
3. **Content Context**
|
||||||
|
- Concerns: Creating and publishing events
|
||||||
|
- Current: `lib/draft-event.ts`, publishing logic in providers
|
||||||
|
- Entities: Note, Reaction, Repost, Bookmark
|
||||||
|
|
||||||
|
4. **Feed Context**
|
||||||
|
- Concerns: Timeline construction, filtering, display
|
||||||
|
- Current: `FeedProvider`, `KindFilterProvider`, `NotificationProvider`
|
||||||
|
- Entities: Feed, Filter, Timeline
|
||||||
|
|
||||||
|
5. **Relay Context**
|
||||||
|
- Concerns: Relay management, connectivity, selection
|
||||||
|
- Current: `FavoriteRelaysProvider`, `ClientService`
|
||||||
|
- Entities: Relay, RelaySet, RelayList
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Anti-Pattern Analysis
|
||||||
|
|
||||||
|
### 3.1 Anemic Domain Model
|
||||||
|
|
||||||
|
**Severity: High**
|
||||||
|
|
||||||
|
The current implementation exhibits a classic anemic domain model. Domain types are primarily data structures without behavior.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current: Types are data containers (src/types/index.d.ts)
|
||||||
|
type TProfile = {
|
||||||
|
pubkey: string
|
||||||
|
username?: string
|
||||||
|
displayName?: string
|
||||||
|
avatar?: string
|
||||||
|
// ... no behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic lives in external functions (src/lib/event-metadata.ts)
|
||||||
|
export function extractProfileFromEventContent(event: Event): TProfile {
|
||||||
|
// Logic external to the domain object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Business rules scattered across `lib/`, `services/`, `providers/`
|
||||||
|
- Difficult to find all rules related to a concept
|
||||||
|
- Easy to bypass validation by directly manipulating data
|
||||||
|
|
||||||
|
### 3.2 Smart UI Tendencies
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
Some business logic exists in UI components and providers that should be in domain layer.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Provider contains domain logic (src/providers/FollowListProvider.tsx)
|
||||||
|
const follow = async (pubkey: string) => {
|
||||||
|
// Business rule: can't follow yourself
|
||||||
|
if (pubkey === currentPubkey) return
|
||||||
|
|
||||||
|
// Business rule: avoid duplicates
|
||||||
|
if (followList.includes(pubkey)) return
|
||||||
|
|
||||||
|
// Event creation and publishing
|
||||||
|
const newFollowList = [...followList, pubkey]
|
||||||
|
const draftEvent = createFollowListDraftEvent(...)
|
||||||
|
await publish(draftEvent)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This logic belongs in a domain service or aggregate, not in a React context provider.
|
||||||
|
|
||||||
|
### 3.3 Database-Driven Design Elements
|
||||||
|
|
||||||
|
**Severity: Low**
|
||||||
|
|
||||||
|
The `IndexedDB` schema influences some type definitions, though this is less severe than traditional database-driven design.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- Storage keys defined alongside domain constants
|
||||||
|
- Some types mirror storage structure rather than domain concepts
|
||||||
|
|
||||||
|
### 3.4 Missing Aggregate Boundaries
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
No explicit aggregate roots or boundaries exist. Related data is managed independently.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
- `FollowList`, `MuteList`, `PinList` are managed by separate providers
|
||||||
|
- No transactional consistency guarantees
|
||||||
|
- Cross-cutting updates happen independently
|
||||||
|
|
||||||
|
### 3.5 Leaky Abstractions
|
||||||
|
|
||||||
|
**Severity: Medium**
|
||||||
|
|
||||||
|
Infrastructure concerns leak into what should be domain logic.
|
||||||
|
|
||||||
|
**Evidence:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Service mixes domain and infrastructure (src/services/client.service.ts)
|
||||||
|
class ClientService extends EventTarget {
|
||||||
|
private pool = new SimplePool() // Infrastructure
|
||||||
|
private cache = new LRUCache(...) // Infrastructure
|
||||||
|
private userIndex = new FlexSearch(...) // Infrastructure
|
||||||
|
|
||||||
|
// Domain logic mixed with caching, batching, retries
|
||||||
|
async fetchProfile(pubkey: string): Promise<TProfile | null> {
|
||||||
|
// Caching logic
|
||||||
|
// Relay selection logic (domain)
|
||||||
|
// Network calls (infrastructure)
|
||||||
|
// Index updates (infrastructure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Current Strengths
|
||||||
|
|
||||||
|
### 4.1 Natural Domain Event Alignment
|
||||||
|
|
||||||
|
Nostr events ARE domain events. The protocol's event-sourced nature aligns perfectly with DDD:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Nostr events capture domain facts
|
||||||
|
{
|
||||||
|
kind: 1, // Note created
|
||||||
|
content: "Hello Nostr!",
|
||||||
|
tags: [["p", "..."]], // Mentions
|
||||||
|
created_at: 1234567890,
|
||||||
|
pubkey: "...",
|
||||||
|
sig: "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Signer Interface Abstraction
|
||||||
|
|
||||||
|
The `ISigner` interface is a well-designed port in hexagonal architecture terms:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ISigner {
|
||||||
|
getPublicKey(): Promise<string>
|
||||||
|
signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent>
|
||||||
|
nip04Encrypt(pubkey: string, plainText: string): Promise<string>
|
||||||
|
nip04Decrypt(pubkey: string, cipherText: string): Promise<string>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple implementations exist: `NsecSigner`, `Nip07Signer`, `BunkerSigner`, etc.
|
||||||
|
|
||||||
|
### 4.3 Event Creation Factories
|
||||||
|
|
||||||
|
The `lib/draft-event.ts` file contains factory functions that encapsulate event creation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
createShortTextNoteDraftEvent(content, tags?, relays?)
|
||||||
|
createReactionDraftEvent(event, emoji?)
|
||||||
|
createFollowListDraftEvent(tags, content?)
|
||||||
|
createBookmarkDraftEvent(tags, content?)
|
||||||
|
```
|
||||||
|
|
||||||
|
These are proto-factories that could be formalized into proper Factory patterns.
|
||||||
|
|
||||||
|
### 4.4 Clear Type Definitions
|
||||||
|
|
||||||
|
The `types/index.d.ts` file provides a foundation for a rich domain model, even if currently anemic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Refactoring Recommendations
|
||||||
|
|
||||||
|
### 5.1 Phase 1: Establish Domain Layer (Low Risk)
|
||||||
|
|
||||||
|
**Goal:** Create explicit domain layer without disrupting existing functionality.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
1. **Create domain directory structure:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
тФЬтФАтФА domain/
|
||||||
|
тФВ тФЬтФАтФА identity/
|
||||||
|
тФВ тФВ тФЬтФАтФА Account.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА Pubkey.ts (Value Object)
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА social/
|
||||||
|
тФВ тФВ тФЬтФАтФА FollowList.ts (Aggregate)
|
||||||
|
тФВ тФВ тФЬтФАтФА MuteList.ts (Aggregate)
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА content/
|
||||||
|
тФВ тФВ тФЬтФАтФА Note.ts (Entity)
|
||||||
|
тФВ тФВ тФЬтФАтФА Reaction.ts (Value Object)
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА relay/
|
||||||
|
тФВ тФВ тФЬтФАтФА Relay.ts (Value Object)
|
||||||
|
тФВ тФВ тФЬтФАтФА RelaySet.ts (Aggregate)
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФФтФАтФА shared/
|
||||||
|
тФВ тФЬтФАтФА EventId.ts
|
||||||
|
тФВ тФЬтФАтФА Timestamp.ts
|
||||||
|
тФВ тФФтФАтФА index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Introduce Value Objects for primitives:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/identity/Pubkey.ts
|
||||||
|
export class Pubkey {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static fromHex(hex: string): Pubkey {
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
||||||
|
throw new InvalidPubkeyError(hex)
|
||||||
|
}
|
||||||
|
return new Pubkey(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromNpub(npub: string): Pubkey {
|
||||||
|
const decoded = nip19.decode(npub)
|
||||||
|
if (decoded.type !== 'npub') {
|
||||||
|
throw new InvalidPubkeyError(npub)
|
||||||
|
}
|
||||||
|
return new Pubkey(decoded.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
toHex(): string { return this.value }
|
||||||
|
toNpub(): string { return nip19.npubEncode(this.value) }
|
||||||
|
|
||||||
|
equals(other: Pubkey): boolean {
|
||||||
|
return this.value === other.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/relay/RelayUrl.ts
|
||||||
|
export class RelayUrl {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static create(url: string): RelayUrl {
|
||||||
|
const normalized = normalizeRelayUrl(url)
|
||||||
|
if (!isValidRelayUrl(normalized)) {
|
||||||
|
throw new InvalidRelayUrlError(url)
|
||||||
|
}
|
||||||
|
return new RelayUrl(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string { return this.value }
|
||||||
|
|
||||||
|
equals(other: RelayUrl): boolean {
|
||||||
|
return this.value === other.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create rich domain entities:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/social/FollowList.ts
|
||||||
|
export class FollowList {
|
||||||
|
private constructor(
|
||||||
|
private readonly _ownerPubkey: Pubkey,
|
||||||
|
private _following: Set<string>,
|
||||||
|
private _petnames: Map<string, string>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static empty(owner: Pubkey): FollowList {
|
||||||
|
return new FollowList(owner, new Set(), new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEvent(event: Event): FollowList {
|
||||||
|
// Reconstitute from Nostr event
|
||||||
|
}
|
||||||
|
|
||||||
|
follow(pubkey: Pubkey): FollowListUpdated {
|
||||||
|
if (pubkey.equals(this._ownerPubkey)) {
|
||||||
|
throw new CannotFollowSelfError()
|
||||||
|
}
|
||||||
|
if (this._following.has(pubkey.toHex())) {
|
||||||
|
return FollowListUpdated.noChange()
|
||||||
|
}
|
||||||
|
this._following.add(pubkey.toHex())
|
||||||
|
return FollowListUpdated.added(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
unfollow(pubkey: Pubkey): FollowListUpdated {
|
||||||
|
if (!this._following.has(pubkey.toHex())) {
|
||||||
|
return FollowListUpdated.noChange()
|
||||||
|
}
|
||||||
|
this._following.delete(pubkey.toHex())
|
||||||
|
return FollowListUpdated.removed(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFollowing(pubkey: Pubkey): boolean {
|
||||||
|
return this._following.has(pubkey.toHex())
|
||||||
|
}
|
||||||
|
|
||||||
|
toDraftEvent(): TDraftEvent {
|
||||||
|
// Convert to publishable event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Phase 2: Introduce Domain Services (Medium Risk)
|
||||||
|
|
||||||
|
**Goal:** Extract business logic from providers into domain services.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
|
||||||
|
1. **Create domain services for cross-aggregate operations:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/content/PublishingService.ts
|
||||||
|
export class PublishingService {
|
||||||
|
constructor(
|
||||||
|
private readonly relaySelector: RelaySelector,
|
||||||
|
private readonly signer: ISigner
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async publishNote(
|
||||||
|
content: string,
|
||||||
|
mentions: Pubkey[],
|
||||||
|
replyTo?: EventId
|
||||||
|
): Promise<PublishedNote> {
|
||||||
|
const note = Note.create(content, mentions, replyTo)
|
||||||
|
const relays = await this.relaySelector.selectForPublishing(note)
|
||||||
|
const signedEvent = await this.signer.signEvent(note.toDraftEvent())
|
||||||
|
|
||||||
|
return new PublishedNote(signedEvent, relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/relay/RelaySelector.ts
|
||||||
|
export class RelaySelector {
|
||||||
|
constructor(
|
||||||
|
private readonly userRelayList: RelayList,
|
||||||
|
private readonly mentionRelayResolver: MentionRelayResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async selectForPublishing(note: Note): Promise<RelayUrl[]> {
|
||||||
|
const writeRelays = this.userRelayList.writeRelays()
|
||||||
|
const mentionRelays = await this.resolveMentionRelays(note.mentions)
|
||||||
|
|
||||||
|
return this.mergeAndDeduplicate(writeRelays, mentionRelays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Refactor providers to use domain services:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/providers/ContentProvider.tsx (refactored)
|
||||||
|
export function ContentProvider({ children }: Props) {
|
||||||
|
const { signer, relayList } = useNostr()
|
||||||
|
|
||||||
|
// Domain service instantiation
|
||||||
|
const publishingService = useMemo(
|
||||||
|
() => new PublishingService(
|
||||||
|
new RelaySelector(relayList, new MentionRelayResolver()),
|
||||||
|
signer
|
||||||
|
),
|
||||||
|
[signer, relayList]
|
||||||
|
)
|
||||||
|
|
||||||
|
const publishNote = useCallback(async (content: string, mentions: string[]) => {
|
||||||
|
const pubkeys = mentions.map(Pubkey.fromHex)
|
||||||
|
const result = await publishingService.publishNote(content, pubkeys)
|
||||||
|
// Update UI state
|
||||||
|
}, [publishingService])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentContext.Provider value={{ publishNote }}>
|
||||||
|
{children}
|
||||||
|
</ContentContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Phase 3: Define Aggregate Boundaries (Higher Risk)
|
||||||
|
|
||||||
|
**Goal:** Establish clear aggregate roots with transactional boundaries.
|
||||||
|
|
||||||
|
**Proposed Aggregates:**
|
||||||
|
|
||||||
|
| Aggregate Root | Child Entities | Invariants |
|
||||||
|
|----------------|----------------|------------|
|
||||||
|
| `UserProfile` | Profile metadata | NIP-05 validation |
|
||||||
|
| `FollowList` | Follow entries, petnames | No self-follow, unique entries |
|
||||||
|
| `MuteList` | Public mutes, private mutes | Encryption for private |
|
||||||
|
| `RelaySet` | Relay URLs, names | Valid URLs, unique within set |
|
||||||
|
| `Bookmark` | Bookmarked events | Unique event references |
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/social/FollowList.ts (Aggregate Root)
|
||||||
|
export class FollowList {
|
||||||
|
private _domainEvents: DomainEvent[] = []
|
||||||
|
|
||||||
|
follow(pubkey: Pubkey): void {
|
||||||
|
// Invariant enforcement
|
||||||
|
this.ensureNotSelf(pubkey)
|
||||||
|
this.ensureNotAlreadyFollowing(pubkey)
|
||||||
|
|
||||||
|
this._following.add(pubkey.toHex())
|
||||||
|
|
||||||
|
// Raise domain event
|
||||||
|
this._domainEvents.push(
|
||||||
|
new UserFollowed(this._ownerPubkey, pubkey, new Date())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pullDomainEvents(): DomainEvent[] {
|
||||||
|
const events = [...this._domainEvents]
|
||||||
|
this._domainEvents = []
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Phase 4: Introduce Repositories (Higher Risk)
|
||||||
|
|
||||||
|
**Goal:** Abstract persistence behind domain-focused interfaces.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/social/FollowListRepository.ts (Interface in domain)
|
||||||
|
export interface FollowListRepository {
|
||||||
|
findByOwner(pubkey: Pubkey): Promise<FollowList | null>
|
||||||
|
save(followList: FollowList): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/infrastructure/persistence/IndexedDbFollowListRepository.ts
|
||||||
|
export class IndexedDbFollowListRepository implements FollowListRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly indexedDb: IndexedDbService,
|
||||||
|
private readonly clientService: ClientService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByOwner(pubkey: Pubkey): Promise<FollowList | null> {
|
||||||
|
// Check IndexedDB cache
|
||||||
|
const cached = await this.indexedDb.getFollowList(pubkey.toHex())
|
||||||
|
if (cached) {
|
||||||
|
return FollowList.fromEvent(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from relays
|
||||||
|
const event = await this.clientService.fetchFollowList(pubkey.toHex())
|
||||||
|
if (event) {
|
||||||
|
await this.indexedDb.saveFollowList(event)
|
||||||
|
return FollowList.fromEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(followList: FollowList): Promise<void> {
|
||||||
|
const draftEvent = followList.toDraftEvent()
|
||||||
|
// Sign and publish handled by application service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Phase 5: Event-Driven Architecture (Advanced)
|
||||||
|
|
||||||
|
**Goal:** Leverage Nostr's event-sourced nature for cross-context communication.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/domain/shared/DomainEvent.ts
|
||||||
|
export abstract class DomainEvent {
|
||||||
|
readonly occurredAt: Date = new Date()
|
||||||
|
abstract get eventType(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/domain/social/events/UserFollowed.ts
|
||||||
|
export class UserFollowed extends DomainEvent {
|
||||||
|
constructor(
|
||||||
|
readonly follower: Pubkey,
|
||||||
|
readonly followed: Pubkey,
|
||||||
|
readonly timestamp: Date
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get eventType(): string { return 'social.user_followed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/application/handlers/UserFollowedHandler.ts
|
||||||
|
export class UserFollowedHandler {
|
||||||
|
constructor(
|
||||||
|
private readonly notificationService: NotificationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: UserFollowed): Promise<void> {
|
||||||
|
// Cross-context reaction
|
||||||
|
await this.notificationService.notifyNewFollower(
|
||||||
|
event.followed,
|
||||||
|
event.follower
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Proposed Target Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
тФЬтФАтФА domain/ # Core domain logic (no dependencies)
|
||||||
|
тФВ тФЬтФАтФА identity/
|
||||||
|
тФВ тФВ тФЬтФАтФА model/
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА Account.ts
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА Pubkey.ts
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА Keypair.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА services/
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА SigningService.ts
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА social/
|
||||||
|
тФВ тФВ тФЬтФАтФА model/
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА FollowList.ts
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА MuteList.ts
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА UserProfile.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА services/
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА TrustCalculator.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА events/
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА UserFollowed.ts
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА UserMuted.ts
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА content/
|
||||||
|
тФВ тФВ тФЬтФАтФА model/
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА Note.ts
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА Reaction.ts
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА Repost.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА services/
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА ContentValidator.ts
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФЬтФАтФА relay/
|
||||||
|
тФВ тФВ тФЬтФАтФА model/
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА RelayUrl.ts
|
||||||
|
тФВ тФВ тФВ тФЬтФАтФА RelaySet.ts
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА RelayList.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА services/
|
||||||
|
тФВ тФВ тФВ тФФтФАтФА RelaySelector.ts
|
||||||
|
тФВ тФВ тФФтФАтФА index.ts
|
||||||
|
тФВ тФФтФАтФА shared/
|
||||||
|
тФВ тФЬтФАтФА EventId.ts
|
||||||
|
тФВ тФЬтФАтФА Timestamp.ts
|
||||||
|
тФВ тФФтФАтФА DomainEvent.ts
|
||||||
|
тФВ
|
||||||
|
тФЬтФАтФА application/ # Use cases, orchestration
|
||||||
|
тФВ тФЬтФАтФА identity/
|
||||||
|
тФВ тФВ тФФтФАтФА AccountService.ts
|
||||||
|
тФВ тФЬтФАтФА social/
|
||||||
|
тФВ тФВ тФЬтФАтФА FollowService.ts
|
||||||
|
тФВ тФВ тФФтФАтФА MuteService.ts
|
||||||
|
тФВ тФЬтФАтФА content/
|
||||||
|
тФВ тФВ тФФтФАтФА PublishingService.ts
|
||||||
|
тФВ тФФтФАтФА handlers/
|
||||||
|
тФВ тФФтФАтФА DomainEventHandlers.ts
|
||||||
|
тФВ
|
||||||
|
тФЬтФАтФА infrastructure/ # External concerns
|
||||||
|
тФВ тФЬтФАтФА persistence/
|
||||||
|
тФВ тФВ тФЬтФАтФА IndexedDbRepository.ts
|
||||||
|
тФВ тФВ тФФтФАтФА LocalStorageRepository.ts
|
||||||
|
тФВ тФЬтФАтФА nostr/
|
||||||
|
тФВ тФВ тФЬтФАтФА NostrClient.ts
|
||||||
|
тФВ тФВ тФФтФАтФА RelayPool.ts
|
||||||
|
тФВ тФЬтФАтФА signing/
|
||||||
|
тФВ тФВ тФЬтФАтФА NsecSigner.ts
|
||||||
|
тФВ тФВ тФЬтФАтФА Nip07Signer.ts
|
||||||
|
тФВ тФВ тФФтФАтФА BunkerSigner.ts
|
||||||
|
тФВ тФФтФАтФА translation/
|
||||||
|
тФВ тФФтФАтФА TranslationApiClient.ts
|
||||||
|
тФВ
|
||||||
|
тФЬтФАтФА presentation/ # React components
|
||||||
|
тФВ тФЬтФАтФА providers/ # Thin wrappers around application services
|
||||||
|
тФВ тФЬтФАтФА components/
|
||||||
|
тФВ тФЬтФАтФА pages/
|
||||||
|
тФВ тФФтФАтФА hooks/
|
||||||
|
тФВ
|
||||||
|
тФФтФАтФА shared/ # Cross-cutting utilities
|
||||||
|
тФЬтФАтФА lib/
|
||||||
|
тФФтФАтФА constants/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration Strategy
|
||||||
|
|
||||||
|
### 7.1 Incremental Approach
|
||||||
|
|
||||||
|
1. **Week 1-2:** Create `domain/shared/` with Value Objects (Pubkey, RelayUrl, EventId)
|
||||||
|
2. **Week 3-4:** Migrate one bounded context (recommend: Social Graph)
|
||||||
|
3. **Week 5-6:** Add domain services, refactor related providers
|
||||||
|
4. **Week 7-8:** Introduce repositories for the migrated context
|
||||||
|
5. **Ongoing:** Repeat for remaining contexts
|
||||||
|
|
||||||
|
### 7.2 Coexistence Strategy
|
||||||
|
|
||||||
|
During migration, old and new code can coexist:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Adapter to bridge old and new
|
||||||
|
export function legacyPubkeyToDomain(pubkey: string): Pubkey {
|
||||||
|
return Pubkey.fromHex(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainPubkeyToLegacy(pubkey: Pubkey): string {
|
||||||
|
return pubkey.toHex()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Testing Strategy
|
||||||
|
|
||||||
|
- Unit test domain objects in isolation
|
||||||
|
- Integration test application services
|
||||||
|
- Keep existing component tests as regression safety
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Metrics for Success
|
||||||
|
|
||||||
|
| Metric | Current State | Target State |
|
||||||
|
|--------|---------------|--------------|
|
||||||
|
| Domain logic in providers | ~60% | <10% |
|
||||||
|
| Value Objects usage | 0 | 15+ types |
|
||||||
|
| Explicit aggregates | 0 | 5 aggregates |
|
||||||
|
| Domain events | 0 (implicit) | 10+ event types |
|
||||||
|
| Repository interfaces | 0 | 5 repositories |
|
||||||
|
| Test coverage (domain) | N/A | >80% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Probability | Impact | Mitigation |
|
||||||
|
|------|-------------|--------|------------|
|
||||||
|
| Breaking changes during migration | Medium | High | Incremental migration, adapter layer |
|
||||||
|
| Performance regression | Low | Medium | Benchmark critical paths, optimize lazily |
|
||||||
|
| Team learning curve | Medium | Medium | Documentation, pair programming |
|
||||||
|
| Over-engineering | Medium | Medium | YAGNI principle, concrete before abstract |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Conclusion
|
||||||
|
|
||||||
|
The Smesh codebase has a solid foundation that can be evolved toward DDD principles. The key recommendations are:
|
||||||
|
|
||||||
|
1. **Immediate:** Introduce Value Objects for Pubkey, RelayUrl, EventId
|
||||||
|
2. **Short-term:** Create rich domain entities with behavior
|
||||||
|
3. **Medium-term:** Extract domain services from providers
|
||||||
|
4. **Long-term:** Full bounded context separation with repositories
|
||||||
|
|
||||||
|
The Nostr protocol's event-sourced nature is a natural fit for DDD, and the existing type definitions provide a starting point for a rich domain model. The main effort will be moving from an anemic model to entities with behavior, and establishing clear aggregate boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated: December 2024*
|
||||||
|
*Analysis based on DDD principles from Eric Evans and Vaughn Vernon*
|
||||||
24
README.md
@@ -1,32 +1,32 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<picture>
|
<picture>
|
||||||
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" />
|
<img src="./resources/logo-light.svg" alt="Smesh Logo" width="400" />
|
||||||
</picture>
|
</picture>
|
||||||
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
|
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Jumble
|
# Smesh
|
||||||
|
|
||||||
A user-friendly Nostr client for exploring relay feeds
|
A user-friendly Nostr client for exploring relay feeds
|
||||||
|
|
||||||
Experience Jumble at [https://jumble.social](https://jumble.social)
|
Experience Smesh at [https://smesh.social](https://smesh.social)
|
||||||
|
|
||||||
## Forks
|
## Forks
|
||||||
|
|
||||||
> Some interesting forks of Jumble.
|
> Some interesting forks of Smesh.
|
||||||
|
|
||||||
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://jumble.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
|
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://smesh.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
|
||||||
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://jumble.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
|
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://smesh.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)
|
- [https://smesh.imwald.eu/](https://smesh.imwald.eu/) Repo: [Silberengel/smesh](https://github.com/Silberengel/smesh) - by [@Silberengel](https://smesh.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone this repository
|
# Clone this repository
|
||||||
git clone https://github.com/CodyTseng/jumble.git
|
git clone https://git.mleku.dev/mleku/smesh.git
|
||||||
|
|
||||||
# Go into the repository
|
# Go into the repository
|
||||||
cd jumble
|
cd smesh
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
@@ -39,10 +39,10 @@ npm run dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone this repository
|
# Clone this repository
|
||||||
git clone https://github.com/CodyTseng/jumble.git
|
git clone https://git.mleku.dev/mleku/smesh.git
|
||||||
|
|
||||||
# Go into the repository
|
# Go into the repository
|
||||||
cd jumble
|
cd smesh
|
||||||
|
|
||||||
# Run the docker compose
|
# Run the docker compose
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
@@ -62,7 +62,7 @@ If you like this project, you can buy me a coffee :)
|
|||||||
|
|
||||||
- **Lightning:** тЪбя╕П codytseng@getalby.com тЪбя╕П
|
- **Lightning:** тЪбя╕П codytseng@getalby.com тЪбя╕П
|
||||||
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
|
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
|
||||||
- **Geyser:** https://geyser.fund/project/jumble
|
- **Geyser:** https://geyser.fund/project/smesh
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
services:
|
services:
|
||||||
jumble:
|
smesh:
|
||||||
container_name: jumble-nginx
|
container_name: smesh-nginx
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
|
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||||
ports:
|
ports:
|
||||||
- '8089:80'
|
- '8089:80'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- jumble
|
- smesh
|
||||||
|
|
||||||
proxy-server:
|
proxy-server:
|
||||||
image: ghcr.io/danvergara/jumble-proxy-server:latest
|
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||||
environment:
|
environment:
|
||||||
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
|
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||||
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
|
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||||
- ENABLE_PPROF=true
|
- ENABLE_PPROF=true
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
ports:
|
ports:
|
||||||
- '8090:8080'
|
- '8090:8080'
|
||||||
networks:
|
networks:
|
||||||
- jumble
|
- smesh
|
||||||
|
|
||||||
nostr-relay:
|
nostr-relay:
|
||||||
image: scsibug/nostr-rs-relay:latest
|
image: scsibug/nostr-rs-relay:latest
|
||||||
container_name: jumble-nostr-relay
|
container_name: smesh-nostr-relay
|
||||||
ports:
|
ports:
|
||||||
- '7000:8080'
|
- '7000:8080'
|
||||||
environment:
|
environment:
|
||||||
@@ -34,11 +34,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- relay-data:/usr/src/app/db
|
- relay-data:/usr/src/app/db
|
||||||
networks:
|
networks:
|
||||||
- jumble
|
- smesh
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
relay-data:
|
relay-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
jumble:
|
smesh:
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
jumble:
|
smesh:
|
||||||
container_name: jumble-nginx
|
container_name: smesh-nginx
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
|
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||||
ports:
|
ports:
|
||||||
- '8089:80'
|
- '8089:80'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- jumble
|
- smesh
|
||||||
|
|
||||||
proxy-server:
|
proxy-server:
|
||||||
image: ghcr.io/danvergara/jumble-proxy-server:latest
|
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||||
environment:
|
environment:
|
||||||
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
|
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||||
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
|
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||||
- ENABLE_PPROF=true
|
- ENABLE_PPROF=true
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
ports:
|
ports:
|
||||||
- '8090:8080'
|
- '8090:8080'
|
||||||
networks:
|
networks:
|
||||||
- jumble
|
- smesh
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
jumble:
|
smesh:
|
||||||
|
|||||||
37
index.html
@@ -4,30 +4,53 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
<title>Jumble</title>
|
<title>Smesh</title>
|
||||||
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
|
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
|
||||||
<meta
|
<meta
|
||||||
name="keywords"
|
name="keywords"
|
||||||
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"
|
content="smesh, nostr, web, client, relay, feed, social, pwa, simple, clean"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Jumble" />
|
<meta name="apple-mobile-web-app-title" content="Smesh" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.png" sizes="256x256" type="image/png" />
|
||||||
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
||||||
|
|
||||||
<meta property="og:url" content="https://jumble.social" />
|
<meta property="og:url" content="https://smesh.mleku.dev" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Jumble" />
|
<meta property="og:title" content="Smesh" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="A user-friendly Nostr client for exploring relay feeds"
|
content="A user-friendly Nostr client for exploring relay feeds"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:image"
|
property="og:image"
|
||||||
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true"
|
content="https://git.mleku.dev/mleku/smesh/raw/branch/master/resources/og-image.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Prevent flash - set background based on system preference before CSS loads */
|
||||||
|
html, body, #root { background-color: #FFFFFF; color: #171717; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body, #root { background-color: #171717; color: #FAFAFA; }
|
||||||
|
}
|
||||||
|
html.light, html.light body, html.light #root { background-color: #FFFFFF !important; color: #171717 !important; }
|
||||||
|
html.dark, html.dark body, html.dark #root { background-color: #171717 !important; color: #FAFAFA !important; }
|
||||||
|
html.dark.pure-black, html.dark.pure-black body, html.dark.pure-black #root { background-color: #000000 !important; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var theme = localStorage.getItem('theme-setting');
|
||||||
|
if (theme === 'light') {
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
} else if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else if (theme === 'pure-black') {
|
||||||
|
document.documentElement.classList.add('dark', 'pure-black');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1239
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "jumble",
|
"name": "smesh",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -8,11 +8,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/CodyTseng/jumble"
|
"url": "git+https://git.mleku.dev/mleku/smesh"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/CodyTseng/jumble",
|
"homepage": "https://git.mleku.dev/mleku/smesh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
"dev:8080": "vite --host 0.0.0.0 --port 8080",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@getalby/bitcoin-connect-react": "^3.10.0",
|
"@getalby/bitcoin-connect-react": "^3.10.0",
|
||||||
"@noble/hashes": "^1.6.1",
|
"@noble/hashes": "^1.6.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"names": {
|
|
||||||
"_": "f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a",
|
|
||||||
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
|
|
||||||
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
BIN
resources/smeshdark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
resources/smeshicondark.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
resources/smeshiconlight.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
resources/smeshlight.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
22
src/App.tsx
@@ -15,10 +15,8 @@ import { MuteListProvider } from '@/providers/MuteListProvider'
|
|||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
import { ReplyProvider } from '@/providers/ReplyProvider'
|
|
||||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
|
||||||
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
|
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
|
||||||
import { UserTrustProvider } from '@/providers/UserTrustProvider'
|
import { UserTrustProvider } from '@/providers/UserTrustProvider'
|
||||||
import { ZapProvider } from '@/providers/ZapProvider'
|
import { ZapProvider } from '@/providers/ZapProvider'
|
||||||
@@ -33,8 +31,7 @@ export default function App(): JSX.Element {
|
|||||||
<DeletedEventProvider>
|
<DeletedEventProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
<TranslationServiceProvider>
|
<FavoriteRelaysProvider>
|
||||||
<FavoriteRelaysProvider>
|
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
<UserTrustProvider>
|
<UserTrustProvider>
|
||||||
@@ -43,14 +40,12 @@ export default function App(): JSX.Element {
|
|||||||
<PinListProvider>
|
<PinListProvider>
|
||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<ReplyProvider>
|
<MediaUploadServiceProvider>
|
||||||
<MediaUploadServiceProvider>
|
<KindFilterProvider>
|
||||||
<KindFilterProvider>
|
<PageManager />
|
||||||
<PageManager />
|
<Toaster />
|
||||||
<Toaster />
|
</KindFilterProvider>
|
||||||
</KindFilterProvider>
|
</MediaUploadServiceProvider>
|
||||||
</MediaUploadServiceProvider>
|
|
||||||
</ReplyProvider>
|
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</PinnedUsersProvider>
|
</PinnedUsersProvider>
|
||||||
</PinListProvider>
|
</PinListProvider>
|
||||||
@@ -59,8 +54,7 @@ export default function App(): JSX.Element {
|
|||||||
</UserTrustProvider>
|
</UserTrustProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
</TranslationServiceProvider>
|
|
||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</DeletedEventProvider>
|
</DeletedEventProvider>
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
|
import { useTheme } from '@/providers/ThemeProvider'
|
||||||
|
import iconLight from './smeshiconlight.png'
|
||||||
|
import iconDark from './smeshicondark.png'
|
||||||
|
|
||||||
export default function Icon({ className }: { className?: string }) {
|
export default function Icon({ className }: { className?: string }) {
|
||||||
return (
|
const { theme } = useTheme()
|
||||||
<svg
|
const iconSrc = theme === 'light' ? iconLight : iconDark
|
||||||
viewBox="0 0 1080 1228"
|
|
||||||
version="1.1"
|
return <img src={iconSrc} alt="Smesh" className={className} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
style={{
|
|
||||||
fill: 'currentcolor',
|
|
||||||
fillRule: 'evenodd',
|
|
||||||
clipRule: 'evenodd',
|
|
||||||
strokeLinejoin: 'round',
|
|
||||||
strokeMiterlimit: 2
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
id="Icon-Curve-Cut"
|
|
||||||
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 47 KiB |
BIN
src/assets/smeshdark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/smeshicondark.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/smeshiconlight.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/assets/smeshlight.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -11,7 +11,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<div className="text-xl font-semibold">Jumble</div>
|
<div className="text-xl font-semibold">Smesh</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
A user-friendly Nostr client for exploring relay feeds
|
A user-friendly Nostr client for exploring relay feeds
|
||||||
</div>
|
</div>
|
||||||
@@ -21,15 +21,15 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
|||||||
<div>
|
<div>
|
||||||
Source code:{' '}
|
Source code:{' '}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/CodyTseng/jumble"
|
href="https://git.mleku.dev/mleku/smesh"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
GitHub
|
Git
|
||||||
</a>
|
</a>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
If you like Jumble, please consider giving it a star тнР
|
If you like Smesh, please consider giving it a star тнР
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import { Check, Copy, RefreshCcw } from 'lucide-react'
|
|
||||||
import { generateSecretKey } from 'nostr-tools'
|
|
||||||
import { nsecEncode } from 'nostr-tools/nip19'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function GenerateNewAccount({
|
|
||||||
back,
|
|
||||||
onLoginSuccess
|
|
||||||
}: {
|
|
||||||
back: () => void
|
|
||||||
onLoginSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { nsecLogin } = useNostr()
|
|
||||||
const [nsec, setNsec] = useState(generateNsec())
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleLogin()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-orange-400">
|
|
||||||
{t(
|
|
||||||
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>nsec</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input value={nsec} />
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
|
|
||||||
<RefreshCcw />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(nsec)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? <Check /> : <Copy />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="password-input">{t('password')}</Label>
|
|
||||||
<Input
|
|
||||||
id="password-input"
|
|
||||||
type="password"
|
|
||||||
placeholder={t('optional: encrypt nsec')}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
|
|
||||||
{t('Back')}
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1" type="submit">
|
|
||||||
{t('Login')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateNsec() {
|
|
||||||
const sk = generateSecretKey()
|
|
||||||
return nsecEncode(sk)
|
|
||||||
}
|
|
||||||
227
src/components/AccountManager/Signup.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { Check, Copy, Download, RefreshCcw } from 'lucide-react'
|
||||||
|
import { generateSecretKey } from 'nostr-tools'
|
||||||
|
import { nsecEncode } from 'nostr-tools/nip19'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import InfoCard from '../InfoCard'
|
||||||
|
|
||||||
|
type Step = 'generate' | 'password'
|
||||||
|
|
||||||
|
export default function Signup({
|
||||||
|
back,
|
||||||
|
onSignupSuccess
|
||||||
|
}: {
|
||||||
|
back: () => void
|
||||||
|
onSignupSuccess: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { nsecLogin } = useNostr()
|
||||||
|
const [step, setStep] = useState<Step>('generate')
|
||||||
|
const [nsec, setNsec] = useState(generateNsec())
|
||||||
|
const [checkedSaveKey, setCheckedSaveKey] = useState(false)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const blob = new Blob([nsec], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'nostr-private-key.txt'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignup = async () => {
|
||||||
|
await nsecLogin(nsec, password || undefined, true)
|
||||||
|
onSignupSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordsMatch = password === confirmPassword
|
||||||
|
const canSubmit = !password || passwordsMatch
|
||||||
|
|
||||||
|
const renderStepIndicator = () => (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{(['generate', 'password'] as Step[]).map((s, index) => (
|
||||||
|
<div key={s} className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${
|
||||||
|
step === s
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: step === 'password' && s === 'generate'
|
||||||
|
? 'bg-primary/20 text-primary'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{index < 1 && <div className="w-12 h-0.5 bg-muted mx-1" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (step === 'generate') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{renderStepIndicator()}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{t('Create Your Nostr Account')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Generate your unique private key. This is your digital identity.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoCard
|
||||||
|
variant="alert"
|
||||||
|
title={t('Critical: Save Your Private Key')}
|
||||||
|
content={t(
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('Your Private Key')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={nsec}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-sm"
|
||||||
|
onClick={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setNsec(generateNsec())}
|
||||||
|
title={t('Generate new key')}
|
||||||
|
>
|
||||||
|
<RefreshCcw />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-wrap gap-2">
|
||||||
|
<Button onClick={handleDownload} className="flex-1">
|
||||||
|
<Download />
|
||||||
|
{t('Download Backup File')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(nsec)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{copied ? <Check /> : <Copy />}
|
||||||
|
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-2">
|
||||||
|
<Checkbox
|
||||||
|
id="acknowledge-checkbox"
|
||||||
|
checked={checkedSaveKey}
|
||||||
|
onCheckedChange={(c) => setCheckedSaveKey(!!c)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="acknowledge-checkbox" className="cursor-pointer">
|
||||||
|
{t('I have safely backed up my private key')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={back} className="w-fit px-6">
|
||||||
|
{t('Back')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={() => setStep('password')} className="flex-1" disabled={!checkedSaveKey}>
|
||||||
|
{t('Continue')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// step === 'password'
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{renderStepIndicator()}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{t('Secure Your Account')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Add an extra layer of protection with a password')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoCard
|
||||||
|
title={t('Password Protection (Recommended)')}
|
||||||
|
content={t(
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="password-input">{t('Password (Optional)')}</Label>
|
||||||
|
<Input
|
||||||
|
id="password-input"
|
||||||
|
type="password"
|
||||||
|
placeholder={t('Create a password (or skip)')}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{password && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="confirm-password-input">{t('Confirm Password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password-input"
|
||||||
|
type="password"
|
||||||
|
placeholder={t('Enter your password again')}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{confirmPassword && !passwordsMatch && (
|
||||||
|
<p className="text-xs text-red-500">{t('Passwords do not match')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('generate')
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
}}
|
||||||
|
className="w-fit px-6"
|
||||||
|
>
|
||||||
|
{t('Back')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSignup} className="flex-1" disabled={!canSubmit}>
|
||||||
|
{t('Complete Signup')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNsec() {
|
||||||
|
const sk = generateSecretKey()
|
||||||
|
return nsecEncode(sk)
|
||||||
|
}
|
||||||
@@ -2,17 +2,15 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { isDevEnv } from '@/lib/utils'
|
import { isDevEnv } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useTheme } from '@/providers/ThemeProvider'
|
|
||||||
import { NstartModal } from 'nstart-modal'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AccountList from '../AccountList'
|
import AccountList from '../AccountList'
|
||||||
import GenerateNewAccount from './GenerateNewAccount'
|
|
||||||
import NostrConnectLogin from './NostrConnectionLogin'
|
import NostrConnectLogin from './NostrConnectionLogin'
|
||||||
import NpubLogin from './NpubLogin'
|
import NpubLogin from './NpubLogin'
|
||||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||||
|
import Signup from './Signup'
|
||||||
|
|
||||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
|
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
|
||||||
|
|
||||||
export default function AccountManager({ close }: { close?: () => void }) {
|
export default function AccountManager({ close }: { close?: () => void }) {
|
||||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||||
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
|||||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'bunker' ? (
|
) : page === 'bunker' ? (
|
||||||
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'generate' ? (
|
|
||||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
|
||||||
) : page === 'npub' ? (
|
) : page === 'npub' ? (
|
||||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
|
) : page === 'signup' ? (
|
||||||
|
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
|
||||||
) : (
|
) : (
|
||||||
<AccountManagerNav setPage={setPage} close={close} />
|
<AccountManagerNav setPage={setPage} close={close} />
|
||||||
)}
|
)}
|
||||||
@@ -41,9 +39,8 @@ function AccountManagerNav({
|
|||||||
setPage: (page: TAccountManagerPage) => void
|
setPage: (page: TAccountManagerPage) => void
|
||||||
close?: () => void
|
close?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { themeSetting } = useTheme()
|
const { nip07Login, accounts } = useNostr()
|
||||||
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
|
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
|
||||||
@@ -75,38 +72,8 @@ function AccountManagerNav({
|
|||||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||||
{t("Don't have an account yet?")}
|
{t("Don't have an account yet?")}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={() => setPage('signup')} className="w-full mt-4">
|
||||||
onClick={() => {
|
{t('Create New Account')}
|
||||||
const wizard = new NstartModal({
|
|
||||||
baseUrl: 'https://nstart.me',
|
|
||||||
an: 'Jumble',
|
|
||||||
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
|
|
||||||
al: i18n.language.slice(0, 2),
|
|
||||||
onComplete: ({ nostrLogin }) => {
|
|
||||||
if (!nostrLogin) return
|
|
||||||
|
|
||||||
if (nostrLogin.startsWith('bunker://')) {
|
|
||||||
bunkerLogin(nostrLogin)
|
|
||||||
} else if (nostrLogin.startsWith('ncryptsec')) {
|
|
||||||
ncryptsecLogin(nostrLogin)
|
|
||||||
} else if (nostrLogin.startsWith('nsec')) {
|
|
||||||
nsecLogin(nostrLogin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
close?.()
|
|
||||||
wizard.open()
|
|
||||||
}}
|
|
||||||
className="w-full mt-4"
|
|
||||||
>
|
|
||||||
{t('Sign up')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
onClick={() => setPage('generate')}
|
|
||||||
className="w-full text-muted-foreground py-0 h-fit mt-1"
|
|
||||||
>
|
|
||||||
{t('or simply generate a private key')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import {
|
import {
|
||||||
EmbeddedEmojiParser,
|
EmbeddedEmojiParser,
|
||||||
EmbeddedEventParser,
|
EmbeddedEventParser,
|
||||||
@@ -15,7 +14,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload from '@/services/media-upload.service'
|
||||||
import { TImetaInfo } from '@/types'
|
import { TImetaInfo } from '@/types'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
EmbeddedHashtag,
|
EmbeddedHashtag,
|
||||||
EmbeddedLNInvoice,
|
EmbeddedLNInvoice,
|
||||||
@@ -25,8 +24,10 @@ import {
|
|||||||
} from '../Embedded'
|
} from '../Embedded'
|
||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
import ExternalLink from '../ExternalLink'
|
import ExternalLink from '../ExternalLink'
|
||||||
|
import HighlightButton from '../HighlightButton'
|
||||||
import ImageGallery from '../ImageGallery'
|
import ImageGallery from '../ImageGallery'
|
||||||
import MediaPlayer from '../MediaPlayer'
|
import MediaPlayer from '../MediaPlayer'
|
||||||
|
import PostEditor from '../PostEditor'
|
||||||
import WebPreview from '../WebPreview'
|
import WebPreview from '../WebPreview'
|
||||||
import XEmbeddedPost from '../XEmbeddedPost'
|
import XEmbeddedPost from '../XEmbeddedPost'
|
||||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||||
@@ -35,16 +36,20 @@ export default function Content({
|
|||||||
event,
|
event,
|
||||||
content,
|
content,
|
||||||
className,
|
className,
|
||||||
mustLoadMedia
|
mustLoadMedia,
|
||||||
|
enableHighlight = false
|
||||||
}: {
|
}: {
|
||||||
event?: Event
|
event?: Event
|
||||||
content?: string
|
content?: string
|
||||||
className?: string
|
className?: string
|
||||||
mustLoadMedia?: boolean
|
mustLoadMedia?: boolean
|
||||||
|
enableHighlight?: boolean
|
||||||
}) {
|
}) {
|
||||||
const translatedEvent = useTranslatedEvent(event?.id)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
|
||||||
|
const [selectedText, setSelectedText] = useState('')
|
||||||
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
|
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
|
||||||
const _content = translatedEvent?.content ?? event?.content ?? content
|
const _content = event?.content ?? content
|
||||||
if (!_content) return {}
|
if (!_content) return {}
|
||||||
|
|
||||||
const nodes = parseContent(_content, [
|
const nodes = parseContent(_content, [
|
||||||
@@ -89,87 +94,105 @@ export default function Content({
|
|||||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||||
|
|
||||||
return { nodes, allImages, emojiInfos, lastNormalUrl }
|
return { nodes, allImages, emojiInfos, lastNormalUrl }
|
||||||
}, [event, translatedEvent, content])
|
}, [event, content])
|
||||||
|
|
||||||
if (!nodes || nodes.length === 0) {
|
if (!nodes || nodes.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleHighlight = (text: string) => {
|
||||||
|
setSelectedText(text)
|
||||||
|
setShowHighlightEditor(true)
|
||||||
|
}
|
||||||
|
|
||||||
let imageIndex = 0
|
let imageIndex = 0
|
||||||
return (
|
return (
|
||||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
<>
|
||||||
{nodes.map((node, index) => {
|
<div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||||
if (node.type === 'text') {
|
{nodes.map((node, index) => {
|
||||||
return node.data
|
if (node.type === 'text') {
|
||||||
}
|
return node.data
|
||||||
if (node.type === 'image' || node.type === 'images') {
|
}
|
||||||
const start = imageIndex
|
if (node.type === 'image' || node.type === 'images') {
|
||||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
const start = imageIndex
|
||||||
imageIndex = end
|
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||||
return (
|
imageIndex = end
|
||||||
<ImageGallery
|
return (
|
||||||
className="mt-2"
|
<ImageGallery
|
||||||
key={index}
|
className="mt-2"
|
||||||
images={allImages}
|
key={index}
|
||||||
start={start}
|
images={allImages}
|
||||||
end={end}
|
start={start}
|
||||||
mustLoad={mustLoadMedia}
|
end={end}
|
||||||
/>
|
mustLoad={mustLoadMedia}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
if (node.type === 'media') {
|
}
|
||||||
return (
|
if (node.type === 'media') {
|
||||||
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
|
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 === '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 === '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 === 'websocket-url') {
|
||||||
}
|
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||||
if (node.type === 'event') {
|
}
|
||||||
const id = node.data.split(':')[1]
|
if (node.type === 'event') {
|
||||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
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 === 'mention') {
|
||||||
}
|
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||||
if (node.type === 'hashtag') {
|
}
|
||||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
if (node.type === 'hashtag') {
|
||||||
}
|
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||||
if (node.type === 'emoji') {
|
}
|
||||||
const shortcode = node.data.split(':')[1]
|
if (node.type === 'emoji') {
|
||||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
const shortcode = node.data.split(':')[1]
|
||||||
if (!emoji) return node.data
|
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||||
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
|
if (!emoji) return node.data
|
||||||
}
|
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
|
||||||
if (node.type === 'youtube') {
|
}
|
||||||
return (
|
if (node.type === 'youtube') {
|
||||||
<YoutubeEmbeddedPlayer
|
return (
|
||||||
key={index}
|
<YoutubeEmbeddedPlayer
|
||||||
url={node.data}
|
key={index}
|
||||||
className="mt-2"
|
url={node.data}
|
||||||
mustLoad={mustLoadMedia}
|
className="mt-2"
|
||||||
/>
|
mustLoad={mustLoadMedia}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
if (node.type === 'x-post') {
|
}
|
||||||
return (
|
if (node.type === 'x-post') {
|
||||||
<XEmbeddedPost
|
return (
|
||||||
key={index}
|
<XEmbeddedPost
|
||||||
url={node.data}
|
key={index}
|
||||||
className="mt-2"
|
url={node.data}
|
||||||
mustLoad={mustLoadMedia}
|
className="mt-2"
|
||||||
/>
|
mustLoad={mustLoadMedia}
|
||||||
)
|
/>
|
||||||
}
|
)
|
||||||
return null
|
}
|
||||||
})}
|
return null
|
||||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
})}
|
||||||
</div>
|
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||||
|
</div>
|
||||||
|
{enableHighlight && (
|
||||||
|
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
|
||||||
|
)}
|
||||||
|
{enableHighlight && (
|
||||||
|
<PostEditor
|
||||||
|
highlightedText={selectedText}
|
||||||
|
parentStuff={event}
|
||||||
|
open={showHighlightEditor}
|
||||||
|
setOpen={setShowHighlightEditor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -14,17 +13,12 @@ export default function HighlightPreview({
|
|||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
|
||||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('pointer-events-none', className)}>
|
<div className={cn('pointer-events-none', className)}>
|
||||||
[{t('Highlight')}]{' '}
|
[{t('Highlight')}]{' '}
|
||||||
<Content
|
<Content content={event.content} emojiInfos={emojiInfos} className="italic pr-0.5" />
|
||||||
content={translatedEvent?.content ?? event.content}
|
|
||||||
emojiInfos={emojiInfos}
|
|
||||||
className="italic pr-0.5"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -11,14 +10,7 @@ export default function NormalContentPreview({
|
|||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const translatedEvent = useTranslatedEvent(event?.id)
|
|
||||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
|
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
|
||||||
|
|
||||||
return (
|
return <Content content={event.content} className={className} emojiInfos={emojiInfos} />
|
||||||
<Content
|
|
||||||
content={translatedEvent?.content ?? event.content}
|
|
||||||
className={className}
|
|
||||||
emojiInfos={emojiInfos}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -8,17 +7,12 @@ import Content from './Content'
|
|||||||
|
|
||||||
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
|
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
|
||||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('pointer-events-none', className)}>
|
<div className={cn('pointer-events-none', className)}>
|
||||||
[{t('Poll')}]{' '}
|
[{t('Poll')}]{' '}
|
||||||
<Content
|
<Content content={event.content} emojiInfos={emojiInfos} className="italic pr-0.5" />
|
||||||
content={translatedEvent?.content ?? event.content}
|
|
||||||
emojiInfos={emojiInfos}
|
|
||||||
className="italic pr-0.5"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +1,9 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { JUMBLE_PUBKEY } from '@/constants'
|
import { SMESH_PUBKEY } from '@/constants'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ZapDialog from '../ZapDialog'
|
import ZapDialog from '../ZapDialog'
|
||||||
import PlatinumSponsors from './PlatinumSponsors'
|
|
||||||
import RecentSupporters from './RecentSupporters'
|
import RecentSupporters from './RecentSupporters'
|
||||||
|
|
||||||
export default function Donation({ className }: { className?: string }) {
|
export default function Donation({ className }: { className?: string }) {
|
||||||
@@ -14,9 +13,9 @@ export default function Donation({ className }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
||||||
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
|
<div className="text-center font-semibold">{t('Enjoying Smesh?')}</div>
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
{t('Your donation helps me maintain Jumble and make it better! ЁЯШК')}
|
{t('Your donation helps me maintain Smesh and make it better! ЁЯШК')}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
@@ -40,12 +39,11 @@ export default function Donation({ className }: { className?: string }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<PlatinumSponsors />
|
|
||||||
<RecentSupporters />
|
<RecentSupporters />
|
||||||
<ZapDialog
|
<ZapDialog
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
pubkey={JUMBLE_PUBKEY}
|
pubkey={SMESH_PUBKEY}
|
||||||
defaultAmount={donationAmount}
|
defaultAmount={donationAmount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 |
@@ -33,16 +33,16 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
<p className="text-lg text-center max-w-md">
|
<p className="text-lg text-center max-w-md">
|
||||||
Sorry for the inconvenience. If you don't mind helping, you can{' '}
|
Sorry for the inconvenience. If you don't mind helping, you can{' '}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/CodyTseng/jumble/issues/new"
|
href="https://git.mleku.dev/mleku/smesh/issues/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary underline"
|
className="text-primary underline"
|
||||||
>
|
>
|
||||||
submit an issue on GitHub
|
submit an issue
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
with the error details, or{' '}
|
with the error details, or{' '}
|
||||||
<a
|
<a
|
||||||
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
|
href="https://smesh.mleku.dev/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary underline"
|
className="text-primary underline"
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchRelayInfo } from '@/hooks'
|
import { useFetchRelayInfo } from '@/hooks'
|
||||||
import { toRelay } from '@/lib/link'
|
import { toRelay } from '@/lib/link'
|
||||||
|
import { recommendRelaysByLanguage } from '@/lib/relay'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||||
import relayInfoService from '@/services/relay-info.service'
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { TAwesomeRelayCollection } from '@/types'
|
import { TAwesomeRelayCollection } from '@/types'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
|
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
|
||||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export default function Explore() {
|
export default function Explore() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
|
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
|
||||||
|
const recommendedRelays = useMemo(() => {
|
||||||
|
const lang = i18n.language
|
||||||
|
const relays = recommendRelaysByLanguage(lang)
|
||||||
|
return relays
|
||||||
|
}, [i18n.language])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
relayInfoService.getAwesomeRelayCollections().then(setCollections)
|
relayInfoService.getAwesomeRelayCollections().then(setCollections)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!collections) {
|
if (!collections && recommendedRelays.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="p-4 max-md:border-b">
|
<div className="p-4 max-md:border-b">
|
||||||
@@ -31,9 +39,19 @@ export default function Explore() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{collections.map((collection) => (
|
{recommendedRelays.length > 0 && (
|
||||||
<RelayCollection key={collection.id} collection={collection} />
|
<RelayCollection
|
||||||
))}
|
collection={{
|
||||||
|
id: 'recommended',
|
||||||
|
name: t('Recommended'),
|
||||||
|
relays: recommendedRelays
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{collections &&
|
||||||
|
collections.map((collection) => (
|
||||||
|
<RelayCollection key={collection.id} collection={collection} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
|
|||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function ExternalContentInteractions({
|
export default function ExternalContentInteractions({
|
||||||
pageIndex,
|
|
||||||
externalContent
|
externalContent
|
||||||
}: {
|
}: {
|
||||||
pageIndex?: number
|
|
||||||
externalContent: string
|
externalContent: string
|
||||||
}) {
|
}) {
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
list = <ReplyNoteList stuff={externalContent} />
|
||||||
break
|
break
|
||||||
case 'reactions':
|
case 'reactions':
|
||||||
list = <ReactionList stuff={externalContent} />
|
list = <ReactionList stuff={externalContent} />
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
|||||||
|
|
||||||
const copyShareLink = () => {
|
const copyShareLink = () => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
`https://smesh.mleku.dev/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,39 +60,41 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return isFollowing ? (
|
return isFollowing ? (
|
||||||
<AlertDialog>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialog>
|
||||||
<Button
|
<AlertDialogTrigger asChild>
|
||||||
className="rounded-full min-w-28"
|
<Button
|
||||||
variant={hover ? 'destructive' : 'secondary'}
|
className="rounded-full min-w-28"
|
||||||
disabled={updating}
|
variant={hover ? 'destructive' : 'secondary'}
|
||||||
onMouseEnter={() => setHover(true)}
|
disabled={updating}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseEnter={() => setHover(true)}
|
||||||
>
|
onMouseLeave={() => setHover(false)}
|
||||||
{updating ? (
|
>
|
||||||
<Loader className="animate-spin" />
|
{updating ? (
|
||||||
) : hover ? (
|
<Loader className="animate-spin" />
|
||||||
t('Unfollow')
|
) : hover ? (
|
||||||
) : (
|
t('Unfollow')
|
||||||
t('buttonFollowing')
|
) : (
|
||||||
)}
|
t('buttonFollowing')
|
||||||
</Button>
|
)}
|
||||||
</AlertDialogTrigger>
|
</Button>
|
||||||
<AlertDialogContent>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogHeader>
|
<AlertDialogContent>
|
||||||
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
|
<AlertDialogHeader>
|
||||||
<AlertDialogDescription>
|
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
|
||||||
{t('Are you sure you want to unfollow this user?')}
|
<AlertDialogDescription>
|
||||||
</AlertDialogDescription>
|
{t('Are you sure you want to unfollow this user?')}
|
||||||
</AlertDialogHeader>
|
</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
</AlertDialogHeader>
|
||||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
<AlertDialogFooter>
|
||||||
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
{t('Unfollow')}
|
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
|
||||||
</AlertDialogAction>
|
{t('Unfollow')}
|
||||||
</AlertDialogFooter>
|
</AlertDialogAction>
|
||||||
</AlertDialogContent>
|
</AlertDialogFooter>
|
||||||
</AlertDialog>
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
|
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
|
||||||
{updating ? <Loader className="animate-spin" /> : t('Follow')}
|
{updating ? <Loader className="animate-spin" /> : t('Follow')}
|
||||||
|
|||||||
115
src/components/HighlightButton/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Highlighter } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface HighlightButtonProps {
|
||||||
|
onHighlight: (selectedText: string) => void
|
||||||
|
containerRef?: React.RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HighlightButton({ onHighlight, containerRef }: HighlightButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const [selectedText, setSelectedText] = useState('')
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectionEnd = () => {
|
||||||
|
// Use a small delay to ensure selection is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const text = selection?.toString().trim()
|
||||||
|
|
||||||
|
if (!text || text.length === 0) {
|
||||||
|
setPosition(null)
|
||||||
|
setSelectedText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if selection is within the container (if provided)
|
||||||
|
if (containerRef?.current) {
|
||||||
|
const range = selection?.getRangeAt(0)
|
||||||
|
if (range && !containerRef.current.contains(range.commonAncestorContainer)) {
|
||||||
|
setPosition(null)
|
||||||
|
setSelectedText('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection?.getRangeAt(0)
|
||||||
|
if (!range) return
|
||||||
|
|
||||||
|
// Get the bounding rect of the entire selection
|
||||||
|
const rect = range.getBoundingClientRect()
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
|
||||||
|
|
||||||
|
// Position button above the selection area, centered horizontally
|
||||||
|
setPosition({
|
||||||
|
top: rect.top + scrollTop - 48, // 48px above the selection
|
||||||
|
left: rect.left + scrollLeft + rect.width / 2 // Center of the selection
|
||||||
|
})
|
||||||
|
setSelectedText(text)
|
||||||
|
}, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only listen to mouseup and touchend (when user finishes selection)
|
||||||
|
document.addEventListener('mouseup', handleSelectionEnd)
|
||||||
|
document.addEventListener('touchend', handleSelectionEnd)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mouseup', handleSelectionEnd)
|
||||||
|
document.removeEventListener('touchend', handleSelectionEnd)
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.toString().trim()) {
|
||||||
|
setPosition(null)
|
||||||
|
setSelectedText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!position || !selectedText) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 animate-in fade-in-0 slide-in-from-bottom-4 duration-200"
|
||||||
|
style={{
|
||||||
|
top: `${position.top}px`,
|
||||||
|
left: `${position.left}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
ref={buttonRef}
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="shadow-lg gap-2 -translate-x-1/2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onHighlight(selectedText)
|
||||||
|
// Clear selection after highlighting
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
setPosition(null)
|
||||||
|
setSelectedText('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Highlighter className="h-4 w-4" />
|
||||||
|
{t('Highlight')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -73,13 +73,13 @@ export default function Image({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
|
<div className={cn('relative overflow-hidden rounded-xl', classNames.wrapper)} {...props}>
|
||||||
{/* Spacer: transparent image to maintain dimensions when image is loading */}
|
{/* Spacer: transparent image to maintain dimensions when image is loading */}
|
||||||
{isLoading && dim?.width && dim?.height && (
|
{isLoading && dim?.width && dim?.height && (
|
||||||
<img
|
<img
|
||||||
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
|
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
'object-cover transition-opacity pointer-events-none w-full h-full',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -91,7 +91,7 @@ export default function Image({
|
|||||||
<ThumbHashPlaceholder
|
<ThumbHashPlaceholder
|
||||||
thumbHash={thumbHash}
|
thumbHash={thumbHash}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-full transition-opacity rounded-xl',
|
'w-full h-full transition-opacity',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0'
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -99,14 +99,14 @@ export default function Image({
|
|||||||
<BlurHashCanvas
|
<BlurHashCanvas
|
||||||
blurHash={blurHash}
|
blurHash={blurHash}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-full transition-opacity rounded-xl',
|
'w-full h-full transition-opacity',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0'
|
isLoading ? 'opacity-100' : 'opacity-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-full transition-opacity rounded-xl',
|
'w-full h-full transition-opacity',
|
||||||
isLoading ? 'opacity-100' : 'opacity-0',
|
isLoading ? 'opacity-100' : 'opacity-0',
|
||||||
classNames.skeleton
|
classNames.skeleton
|
||||||
)}
|
)}
|
||||||
@@ -124,7 +124,7 @@ export default function Image({
|
|||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
'object-cover transition-opacity pointer-events-none w-full h-full',
|
||||||
isLoading ? 'opacity-0 absolute inset-0' : '',
|
isLoading ? 'opacity-0 absolute inset-0' : '',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +137,7 @@ export default function Image({
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={cn('object-cover rounded-xl w-full h-full transition-opacity', className)}
|
className={cn('object-cover w-full h-full transition-opacity', className)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export default function ImageGallery({
|
|||||||
<ImageWithLightbox
|
<ImageWithLightbox
|
||||||
key={i}
|
key={i}
|
||||||
image={image}
|
image={image}
|
||||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: cn('w-fit max-w-full', className)
|
wrapper: cn('w-fit max-w-full border', className)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -107,10 +107,10 @@ export default function ImageGallery({
|
|||||||
imageContent = (
|
imageContent = (
|
||||||
<Image
|
<Image
|
||||||
key={0}
|
key={0}
|
||||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||||
classNames={{
|
classNames={{
|
||||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||||
wrapper: 'cursor-zoom-in'
|
wrapper: 'cursor-zoom-in border'
|
||||||
}}
|
}}
|
||||||
image={displayImages[0]}
|
image={displayImages[0]}
|
||||||
onClick={(e) => handlePhotoClick(e, 0)}
|
onClick={(e) => handlePhotoClick(e, 0)}
|
||||||
@@ -122,8 +122,8 @@ export default function ImageGallery({
|
|||||||
{displayImages.map((image, i) => (
|
{displayImages.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
className="aspect-square w-full border"
|
className="aspect-square w-full"
|
||||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||||
image={image}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
@@ -136,8 +136,8 @@ export default function ImageGallery({
|
|||||||
{displayImages.map((image, i) => (
|
{displayImages.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
key={i}
|
||||||
className="aspect-square w-full border"
|
className="aspect-square w-full"
|
||||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||||
image={image}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function ImageWithLightbox({
|
|||||||
key={0}
|
key={0}
|
||||||
className={className}
|
className={className}
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
|
wrapper: cn('border cursor-zoom-in', classNames.wrapper),
|
||||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||||
skeleton: classNames.skeleton
|
skeleton: classNames.skeleton
|
||||||
}}
|
}}
|
||||||
|
|||||||
36
src/components/InfoCard/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CheckCircle2, Info, TriangleAlert } from 'lucide-react'
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
info: <Info />,
|
||||||
|
success: <CheckCircle2 />,
|
||||||
|
alert: <TriangleAlert />
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES = {
|
||||||
|
info: 'bg-blue-100/20 dark:bg-blue-950/20 border border-blue-500 text-blue-500',
|
||||||
|
success: 'bg-green-100/20 dark:bg-green-950/20 border border-green-500 text-green-500',
|
||||||
|
alert: 'bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoCard({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
icon,
|
||||||
|
variant = 'info'
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
variant?: 'info' | 'success' | 'alert'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('p-3 rounded-lg text-sm [&_svg]:size-4', VARIANT_STYLES[variant])}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon ?? ICON_MAP[variant]}
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
</div>
|
||||||
|
{content && <div className="pl-6">{content}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TMailboxRelay } from '@/types'
|
import { TMailboxRelay } from '@/types'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AlertCard from '../AlertCard'
|
import InfoCard from '../InfoCard'
|
||||||
|
|
||||||
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
|
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertCard
|
<InfoCard
|
||||||
|
variant="alert"
|
||||||
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
|
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
|
||||||
content={
|
content={
|
||||||
showReadWarning
|
showReadWarning
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ export default function NormalFeed({
|
|||||||
areAlgoRelays = false,
|
areAlgoRelays = false,
|
||||||
isMainFeed = false,
|
isMainFeed = false,
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
disable24hMode = false
|
disable24hMode = false,
|
||||||
|
onRefresh
|
||||||
}: {
|
}: {
|
||||||
subRequests: TFeedSubRequest[]
|
subRequests: TFeedSubRequest[]
|
||||||
areAlgoRelays?: boolean
|
areAlgoRelays?: boolean
|
||||||
isMainFeed?: boolean
|
isMainFeed?: boolean
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
disable24hMode?: boolean
|
disable24hMode?: boolean
|
||||||
|
onRefresh?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { hideUntrustedNotes } = useUserTrust()
|
const { hideUntrustedNotes } = useUserTrust()
|
||||||
const { showKinds } = useKindFilter()
|
const { showKinds } = useKindFilter()
|
||||||
@@ -65,6 +67,10 @@ export default function NormalFeed({
|
|||||||
{!supportTouch && (
|
{!supportTouch && (
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (listMode === '24h') {
|
if (listMode === '24h') {
|
||||||
userAggregationListRef.current?.refresh()
|
userAggregationListRef.current?.refresh()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function FollowPack({ event, className }: { event: Event; classNa
|
|||||||
{image && (
|
{image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: image, pubkey: event.pubkey }}
|
image={{ url: image, pubkey: event.pubkey }}
|
||||||
className="w-24 h-20 object-cover rounded-lg"
|
className="w-24 h-20 object-cover"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: 'w-24 h-20 flex-shrink-0',
|
wrapper: 'w-24 h-20 flex-shrink-0',
|
||||||
errorPlaceholder: 'w-24 h-20'
|
errorPlaceholder: 'w-24 h-20'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { createFakeEvent } from '@/lib/event'
|
import { createFakeEvent } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { isValidPubkey } from '@/lib/pubkey'
|
import { isValidPubkey } from '@/lib/pubkey'
|
||||||
@@ -14,19 +14,23 @@ import ExternalLink from '../ExternalLink'
|
|||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
||||||
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
|
||||||
const comment = useMemo(
|
const comment = useMemo(
|
||||||
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1],
|
() => event.tags.find((tag) => tag[0] === 'comment')?.[1],
|
||||||
[event, translatedEvent]
|
[event]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
||||||
{comment && <Content event={createFakeEvent({ content: comment })} />}
|
{comment && <Content event={createFakeEvent({ content: comment, tags: event.tags })} />}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
||||||
<div className="italic whitespace-pre-line">
|
<div
|
||||||
{translatedEvent?.content ?? event.content}
|
className="italic whitespace-pre-line"
|
||||||
|
style={{
|
||||||
|
overflowWrap: 'anywhere'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HighlightSource event={event} />
|
<HighlightSource event={event} />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||||
import ImageWithLightbox from '@/components/ImageWithLightbox'
|
import ImageWithLightbox from '@/components/ImageWithLightbox'
|
||||||
|
import HighlightButton from '@/components/HighlightButton'
|
||||||
|
import PostEditor from '@/components/PostEditor'
|
||||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
||||||
import { toNote, toNoteList, toProfile } from '@/lib/link'
|
import { toNote, toNoteList, toProfile } from '@/lib/link'
|
||||||
import { ExternalLink } from 'lucide-react'
|
import { ExternalLink } from 'lucide-react'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import NostrNode from './NostrNode'
|
import NostrNode from './NostrNode'
|
||||||
@@ -20,6 +22,14 @@ export default function LongFormArticle({
|
|||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
|
||||||
|
const [selectedText, setSelectedText] = useState('')
|
||||||
|
|
||||||
|
const handleHighlight = (text: string) => {
|
||||||
|
setSelectedText(text)
|
||||||
|
setShowHighlightEditor(true)
|
||||||
|
}
|
||||||
|
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -74,54 +84,64 @@ export default function LongFormArticle({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}) as Components,
|
}) as Components,
|
||||||
[]
|
[event.pubkey]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
|
<div
|
||||||
>
|
ref={contentRef}
|
||||||
<h1 className="break-words">{metadata.title}</h1>
|
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
|
||||||
{metadata.summary && (
|
|
||||||
<blockquote>
|
|
||||||
<p className="break-words">{metadata.summary}</p>
|
|
||||||
</blockquote>
|
|
||||||
)}
|
|
||||||
{metadata.image && (
|
|
||||||
<ImageWithLightbox
|
|
||||||
image={{ url: metadata.image, pubkey: event.pubkey }}
|
|
||||||
className="w-full aspect-[3/1] object-cover my-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Markdown
|
|
||||||
remarkPlugins={[remarkGfm, remarkNostr]}
|
|
||||||
urlTransform={(url) => {
|
|
||||||
if (url.startsWith('nostr:')) {
|
|
||||||
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}}
|
|
||||||
components={components}
|
|
||||||
>
|
>
|
||||||
{event.content}
|
<h1 className="break-words">{metadata.title}</h1>
|
||||||
</Markdown>
|
{metadata.summary && (
|
||||||
{metadata.tags.length > 0 && (
|
<blockquote>
|
||||||
<div className="flex gap-2 flex-wrap pb-2">
|
<p className="break-words">{metadata.summary}</p>
|
||||||
{metadata.tags.map((tag) => (
|
</blockquote>
|
||||||
<div
|
)}
|
||||||
key={tag}
|
{metadata.image && (
|
||||||
title={tag}
|
<ImageWithLightbox
|
||||||
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||||
onClick={(e) => {
|
className="w-full aspect-[3/1] object-cover my-0"
|
||||||
e.stopPropagation()
|
/>
|
||||||
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
|
)}
|
||||||
}}
|
<Markdown
|
||||||
>
|
remarkPlugins={[remarkGfm, remarkNostr]}
|
||||||
#<span className="truncate">{tag}</span>
|
urlTransform={(url) => {
|
||||||
</div>
|
if (url.startsWith('nostr:')) {
|
||||||
))}
|
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
||||||
</div>
|
}
|
||||||
)}
|
return url
|
||||||
</div>
|
}}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
|
{event.content}
|
||||||
|
</Markdown>
|
||||||
|
{metadata.tags.length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap pb-2">
|
||||||
|
{metadata.tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
title={tag}
|
||||||
|
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#<span className="truncate">{tag}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
|
||||||
|
<PostEditor
|
||||||
|
highlightedText={selectedText}
|
||||||
|
parentStuff={event}
|
||||||
|
open={showHighlightEditor}
|
||||||
|
setOpen={setShowHighlightEditor}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function LongFormArticlePreview({
|
|||||||
{metadata.image && autoLoadMedia && (
|
{metadata.image && autoLoadMedia && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image, pubkey: event.pubkey }}
|
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||||
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
className="aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { POLL_TYPE } from '@/constants'
|
import { POLL_TYPE } from '@/constants'
|
||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import { useFetchPollResults } from '@/hooks/useFetchPollResults'
|
import { useFetchPollResults } from '@/hooks/useFetchPollResults'
|
||||||
import { createPollResponseDraftEvent } from '@/lib/draft-event'
|
import { createPollResponseDraftEvent } from '@/lib/draft-event'
|
||||||
import { getPollMetadataFromEvent } from '@/lib/event-metadata'
|
import { getPollMetadataFromEvent } from '@/lib/event-metadata'
|
||||||
@@ -17,16 +16,12 @@ import { toast } from 'sonner'
|
|||||||
|
|
||||||
export default function Poll({ event, className }: { event: Event; className?: string }) {
|
export default function Poll({ event, className }: { event: Event; className?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
|
||||||
const { pubkey, publish, startLogin } = useNostr()
|
const { pubkey, publish, startLogin } = useNostr()
|
||||||
const [isVoting, setIsVoting] = useState(false)
|
const [isVoting, setIsVoting] = useState(false)
|
||||||
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
|
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
|
||||||
const pollResults = useFetchPollResults(event.id)
|
const pollResults = useFetchPollResults(event.id)
|
||||||
const [isLoadingResults, setIsLoadingResults] = useState(false)
|
const [isLoadingResults, setIsLoadingResults] = useState(false)
|
||||||
const poll = useMemo(
|
const poll = useMemo(() => getPollMetadataFromEvent(event), [event])
|
||||||
() => getPollMetadataFromEvent(translatedEvent ?? event),
|
|
||||||
[event, translatedEvent]
|
|
||||||
)
|
|
||||||
const votedOptionIds = useMemo(() => {
|
const votedOptionIds = useMemo(() => {
|
||||||
if (!pollResults || !pubkey) return []
|
if (!pollResults || !pubkey) return []
|
||||||
return Object.entries(pollResults.results)
|
return Object.entries(pollResults.results)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
|||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import NoteOptions from '../NoteOptions'
|
import NoteOptions from '../NoteOptions'
|
||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import TranslateButton from '../TranslateButton'
|
|
||||||
import TrustScoreBadge from '../TrustScoreBadge'
|
import TrustScoreBadge from '../TrustScoreBadge'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
@@ -117,7 +116,7 @@ export default function Note({
|
|||||||
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
|
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
|
||||||
content = <FollowPack className="mt-2" event={event} />
|
content = <FollowPack className="mt-2" event={event} />
|
||||||
} else {
|
} else {
|
||||||
content = <Content className="mt-2" event={event} />
|
content = <Content className="mt-2" event={event} enableHighlight />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,12 +145,9 @@ export default function Note({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
{size === 'normal' && (
|
||||||
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
|
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
|
||||||
{size === 'normal' && (
|
)}
|
||||||
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{!hideParentNotePreview && (
|
{!hideParentNotePreview && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
|
|||||||
@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
|
|||||||
import ZapList from '../ZapList'
|
import ZapList from '../ZapList'
|
||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function NoteInteractions({
|
export default function NoteInteractions({ event }: { event: Event }) {
|
||||||
pageIndex,
|
|
||||||
event
|
|
||||||
}: {
|
|
||||||
pageIndex?: number
|
|
||||||
event: Event
|
|
||||||
}) {
|
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
list = <ReplyNoteList stuff={event} />
|
||||||
break
|
break
|
||||||
case 'quotes':
|
case 'quotes':
|
||||||
list = <QuoteList stuff={event} />
|
list = <QuoteList stuff={event} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import NewNotesButton from '@/components/NewNotesButton'
|
import NewNotesButton from '@/components/NewNotesButton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||||
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
@@ -7,9 +8,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
|||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TFeedSubRequest } from '@/types'
|
import { TFeedSubRequest } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
@@ -76,11 +77,9 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
const [initialLoading, setInitialLoading] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [filtering, setFiltering] = useState(false)
|
const [filtering, setFiltering] = useState(false)
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [filteredNotes, setFilteredNotes] = useState<
|
const [filteredNotes, setFilteredNotes] = useState<
|
||||||
@@ -88,9 +87,7 @@ const NoteList = forwardRef<
|
|||||||
>([])
|
>([])
|
||||||
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
|
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
|
||||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const topRef = useRef<HTMLDivElement | null>(null)
|
const topRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const shouldHideEvent = useCallback(
|
const shouldHideEvent = useCallback(
|
||||||
@@ -218,10 +215,6 @@ const NoteList = forwardRef<
|
|||||||
processEvents().finally(() => setFiltering(false))
|
processEvents().finally(() => setFiltering(false))
|
||||||
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
|
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
|
||||||
|
|
||||||
const slicedNotes = useMemo(() => {
|
|
||||||
return filteredNotes.slice(0, showCount)
|
|
||||||
}, [filteredNotes, showCount])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const processNewEvents = async () => {
|
const processNewEvents = async () => {
|
||||||
const keySet = new Set<string>()
|
const keySet = new Set<string>()
|
||||||
@@ -273,14 +266,11 @@ const NoteList = forwardRef<
|
|||||||
if (!subRequests.length) return
|
if (!subRequests.length) return
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
setLoading(true)
|
setInitialLoading(true)
|
||||||
setEvents([])
|
setEvents([])
|
||||||
setNewEvents([])
|
setNewEvents([])
|
||||||
setHasMore(true)
|
|
||||||
|
|
||||||
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
|
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
|
||||||
setLoading(false)
|
|
||||||
setHasMore(false)
|
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,12 +295,9 @@ const NoteList = forwardRef<
|
|||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
setEvents(events)
|
setEvents(events)
|
||||||
}
|
}
|
||||||
if (areAlgoRelays) {
|
|
||||||
setHasMore(false)
|
|
||||||
}
|
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
threadService.addRepliesToThread(events)
|
||||||
addReplies(events)
|
setInitialLoading(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
@@ -323,7 +310,7 @@ const NoteList = forwardRef<
|
|||||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
},
|
},
|
||||||
onClose: (url, reason) => {
|
onClose: (url, reason) => {
|
||||||
if (!showRelayCloseReason) return
|
if (!showRelayCloseReason) return
|
||||||
@@ -358,55 +345,26 @@ const NoteList = forwardRef<
|
|||||||
}
|
}
|
||||||
}, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
|
}, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
const options = {
|
if (!timelineKey || areAlgoRelays) return false
|
||||||
root: null,
|
const newEvents = await client.loadMoreTimeline(
|
||||||
rootMargin: '10px',
|
timelineKey,
|
||||||
threshold: 0.1
|
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
||||||
|
LIMIT
|
||||||
|
)
|
||||||
|
if (newEvents.length === 0) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
||||||
|
return true
|
||||||
|
}, [timelineKey, events, areAlgoRelays])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||||
if (showCount < events.length) {
|
items: filteredNotes,
|
||||||
setShowCount((prev) => prev + SHOW_COUNT)
|
showCount: SHOW_COUNT,
|
||||||
// preload more
|
onLoadMore: handleLoadMore,
|
||||||
if (events.length - showCount > LIMIT / 2) {
|
initialLoading
|
||||||
return
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!timelineKey || loading || !hasMore) return
|
|
||||||
setLoading(true)
|
|
||||||
const newEvents = await client.loadMoreTimeline(
|
|
||||||
timelineKey,
|
|
||||||
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
|
||||||
LIMIT
|
|
||||||
)
|
|
||||||
setLoading(false)
|
|
||||||
if (newEvents.length === 0) {
|
|
||||||
setHasMore(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
|
||||||
}
|
|
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => {
|
|
||||||
if (entries[0].isIntersecting && hasMore) {
|
|
||||||
loadMore()
|
|
||||||
}
|
|
||||||
}, options)
|
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current
|
|
||||||
|
|
||||||
if (currentBottomRef) {
|
|
||||||
observerInstance.observe(currentBottomRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (observerInstance && currentBottomRef) {
|
|
||||||
observerInstance.unobserve(currentBottomRef)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [loading, hasMore, events, showCount, timelineKey])
|
|
||||||
|
|
||||||
const showNewEvents = () => {
|
const showNewEvents = () => {
|
||||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||||
@@ -419,7 +377,7 @@ const NoteList = forwardRef<
|
|||||||
const list = (
|
const list = (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||||
{slicedNotes.map(({ key, event, reposters }) => (
|
{visibleItems.map(({ key, event, reposters }) => (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
key={key}
|
key={key}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -428,10 +386,9 @@ const NoteList = forwardRef<
|
|||||||
reposters={reposters}
|
reposters={reposters}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasMore || showCount < events.length || loading || filtering ? (
|
<div ref={bottomRef} />
|
||||||
<div ref={bottomRef}>
|
{shouldShowLoadingIndicator || filtering || initialLoading ? (
|
||||||
<NoteCardLoadingSkeleton />
|
<NoteCardLoadingSkeleton />
|
||||||
</div>
|
|
||||||
) : events.length ? (
|
) : events.length ? (
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Highlighter } from 'lucide-react'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Notification from './Notification'
|
||||||
|
|
||||||
|
export function HighlightNotification({
|
||||||
|
notification,
|
||||||
|
isNew = false
|
||||||
|
}: {
|
||||||
|
notification: Event
|
||||||
|
isNew?: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Notification
|
||||||
|
notificationId={notification.id}
|
||||||
|
icon={<Highlighter size={24} className="text-orange-400" />}
|
||||||
|
sender={notification.pubkey}
|
||||||
|
sentAt={notification.created_at}
|
||||||
|
targetEvent={notification}
|
||||||
|
description={t('highlighted your note')}
|
||||||
|
isNew={isNew}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ export function ReactionNotification({
|
|||||||
<Image
|
<Image
|
||||||
image={{ url: emojiUrl, pubkey: notification.pubkey }}
|
image={{ url: emojiUrl, pubkey: notification.pubkey }}
|
||||||
alt={emojiName}
|
alt={emojiName}
|
||||||
className="w-6 h-6 rounded-md"
|
className="w-6 h-6"
|
||||||
classNames={{ errorPlaceholder: 'bg-transparent' }}
|
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
|
||||||
errorPlaceholder={<Heart size={24} className="text-red-400" />}
|
errorPlaceholder={<Heart size={24} className="text-red-400" />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { HighlightNotification } from './HighlightNotification'
|
||||||
import { MentionNotification } from './MentionNotification'
|
import { MentionNotification } from './MentionNotification'
|
||||||
import { PollResponseNotification } from './PollResponseNotification'
|
import { PollResponseNotification } from './PollResponseNotification'
|
||||||
import { ReactionNotification } from './ReactionNotification'
|
import { ReactionNotification } from './ReactionNotification'
|
||||||
@@ -60,5 +61,8 @@ export function NotificationItem({
|
|||||||
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
|
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
|
||||||
return <PollResponseNotification notification={notification} isNew={isNew} />
|
return <PollResponseNotification notification={notification} isNew={isNew} />
|
||||||
}
|
}
|
||||||
|
if (notification.kind === kinds.Highlights) {
|
||||||
|
return <HighlightNotification notification={notification} isNew={isNew} />
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNotification } from '@/providers/NotificationProvider'
|
import { useNotification } from '@/providers/NotificationProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import stuffStatsService from '@/services/stuff-stats.service'
|
import stuffStatsService from '@/services/stuff-stats.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TNotificationType } from '@/types'
|
import { TNotificationType } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||||
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { getNotificationsSeenAt } = useNotification()
|
const { getNotificationsSeenAt } = useNotification()
|
||||||
const { notificationListStyle } = useUserPreferences()
|
const { notificationListStyle } = useUserPreferences()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||||
const [lastReadTime, setLastReadTime] = useState(0)
|
const [lastReadTime, setLastReadTime] = useState(0)
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
@@ -55,6 +54,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
case 'mentions':
|
case 'mentions':
|
||||||
return [
|
return [
|
||||||
kinds.ShortTextNote,
|
kinds.ShortTextNote,
|
||||||
|
kinds.Highlights,
|
||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.VOICE_COMMENT,
|
ExtendedKind.VOICE_COMMENT,
|
||||||
ExtendedKind.POLL
|
ExtendedKind.POLL
|
||||||
@@ -70,6 +70,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
kinds.GenericRepost,
|
kinds.GenericRepost,
|
||||||
kinds.Reaction,
|
kinds.Reaction,
|
||||||
kinds.Zap,
|
kinds.Zap,
|
||||||
|
kinds.Highlights,
|
||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.POLL_RESPONSE,
|
ExtendedKind.POLL_RESPONSE,
|
||||||
ExtendedKind.VOICE_COMMENT,
|
ExtendedKind.VOICE_COMMENT,
|
||||||
@@ -141,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
addReplies(events)
|
threadService.addRepliesToThread(events)
|
||||||
stuffStatsService.updateStuffStatsByEvents(events)
|
stuffStatsService.updateStuffStatsByEvents(events)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
handleNewEvent(event)
|
handleNewEvent(event)
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
|
|||||||
import { Eraser, X } from 'lucide-react'
|
import { Eraser, X } from 'lucide-react'
|
||||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AlertCard from '../AlertCard'
|
import InfoCard from '../InfoCard'
|
||||||
|
|
||||||
export default function PollEditor({
|
export default function PollEditor({
|
||||||
pollCreateData,
|
pollCreateData,
|
||||||
@@ -125,7 +125,8 @@ export default function PollEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<AlertCard
|
<InfoCard
|
||||||
|
variant="alert"
|
||||||
title={t('This is a poll note.')}
|
title={t('This is a poll note.')}
|
||||||
content={t(
|
content={t(
|
||||||
'Unlike regular notes, polls are not widely supported and may not display on other clients.'
|
'Unlike regular notes, polls are not widely supported and may not display on other clients.'
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import Note from '@/components/Note'
|
import Note from '@/components/Note'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
import {
|
import {
|
||||||
createCommentDraftEvent,
|
createCommentDraftEvent,
|
||||||
|
createHighlightDraftEvent,
|
||||||
createPollDraftEvent,
|
createPollDraftEvent,
|
||||||
createShortTextNoteDraftEvent,
|
createShortTextNoteDraftEvent,
|
||||||
deleteDraftEventCache
|
deleteDraftEventCache
|
||||||
} from '@/lib/draft-event'
|
} from '@/lib/draft-event'
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import postEditorCache from '@/services/post-editor-cache.service'
|
import postEditorCache from '@/services/post-editor-cache.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TPollCreateData } from '@/types'
|
import { TPollCreateData } from '@/types'
|
||||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
@@ -24,22 +26,22 @@ import PostOptions from './PostOptions'
|
|||||||
import PostRelaySelector from './PostRelaySelector'
|
import PostRelaySelector from './PostRelaySelector'
|
||||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||||
import Uploader from './Uploader'
|
import Uploader from './Uploader'
|
||||||
import { BIG_RELAY_URLS } from '@/constants'
|
|
||||||
|
|
||||||
export default function PostContent({
|
export default function PostContent({
|
||||||
defaultContent = '',
|
defaultContent = '',
|
||||||
parentStuff,
|
parentStuff,
|
||||||
close,
|
close,
|
||||||
openFrom
|
openFrom,
|
||||||
|
highlightedText
|
||||||
}: {
|
}: {
|
||||||
defaultContent?: string
|
defaultContent?: string
|
||||||
parentStuff?: Event | string
|
parentStuff?: Event | string
|
||||||
close: () => void
|
close: () => void
|
||||||
openFrom?: string[]
|
openFrom?: string[]
|
||||||
|
highlightedText?: string
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey, publish, checkLogin } = useNostr()
|
const { pubkey, publish, checkLogin } = useNostr()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
@@ -68,7 +70,7 @@ export default function PostContent({
|
|||||||
const canPost = useMemo(() => {
|
const canPost = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
!!pubkey &&
|
!!pubkey &&
|
||||||
!!text &&
|
(!!text || !!highlightedText) &&
|
||||||
!posting &&
|
!posting &&
|
||||||
!uploadProgresses.length &&
|
!uploadProgresses.length &&
|
||||||
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
|
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
|
||||||
@@ -77,6 +79,7 @@ export default function PostContent({
|
|||||||
}, [
|
}, [
|
||||||
pubkey,
|
pubkey,
|
||||||
text,
|
text,
|
||||||
|
highlightedText,
|
||||||
posting,
|
posting,
|
||||||
uploadProgresses,
|
uploadProgresses,
|
||||||
isPoll,
|
isPoll,
|
||||||
@@ -123,30 +126,23 @@ export default function PostContent({
|
|||||||
const post = async (e?: React.MouseEvent) => {
|
const post = async (e?: React.MouseEvent) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
checkLogin(async () => {
|
checkLogin(async () => {
|
||||||
if (!canPost || postingRef.current) return
|
if (!canPost || !pubkey || postingRef.current) return
|
||||||
|
|
||||||
postingRef.current = true
|
postingRef.current = true
|
||||||
setPosting(true)
|
setPosting(true)
|
||||||
try {
|
try {
|
||||||
const draftEvent =
|
const draftEvent = await createDraftEvent({
|
||||||
parentStuff &&
|
parentStuff,
|
||||||
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
|
highlightedText,
|
||||||
? await createCommentDraftEvent(text, parentStuff, mentions, {
|
text,
|
||||||
addClientTag,
|
mentions,
|
||||||
protectedEvent: isProtectedEvent,
|
isPoll,
|
||||||
isNsfw
|
pollCreateData,
|
||||||
})
|
pubkey,
|
||||||
: isPoll
|
addClientTag,
|
||||||
? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, {
|
isProtectedEvent,
|
||||||
addClientTag,
|
isNsfw
|
||||||
isNsfw
|
})
|
||||||
})
|
|
||||||
: await createShortTextNoteDraftEvent(text, mentions, {
|
|
||||||
parentEvent,
|
|
||||||
addClientTag,
|
|
||||||
protectedEvent: isProtectedEvent,
|
|
||||||
isNsfw
|
|
||||||
})
|
|
||||||
|
|
||||||
const _additionalRelayUrls = [...additionalRelayUrls]
|
const _additionalRelayUrls = [...additionalRelayUrls]
|
||||||
if (parentStuff && typeof parentStuff === 'string') {
|
if (parentStuff && typeof parentStuff === 'string') {
|
||||||
@@ -160,7 +156,7 @@ export default function PostContent({
|
|||||||
})
|
})
|
||||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||||
deleteDraftEventCache(draftEvent)
|
deleteDraftEventCache(draftEvent)
|
||||||
addReplies([newEvent])
|
threadService.addRepliesToThread([newEvent])
|
||||||
toast.success(t('Post successful'), { duration: 2000 })
|
toast.success(t('Post successful'), { duration: 2000 })
|
||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -205,7 +201,14 @@ export default function PostContent({
|
|||||||
{parentEvent && (
|
{parentEvent && (
|
||||||
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
|
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
|
||||||
<div className="p-2 sm:p-3 pointer-events-none">
|
<div className="p-2 sm:p-3 pointer-events-none">
|
||||||
<Note size="small" event={parentEvent} hideParentNotePreview />
|
{highlightedText ? (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
||||||
|
<div className="italic whitespace-pre-line">{highlightedText}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Note size="small" event={parentEvent} hideParentNotePreview />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
@@ -220,6 +223,7 @@ export default function PostContent({
|
|||||||
onUploadStart={handleUploadStart}
|
onUploadStart={handleUploadStart}
|
||||||
onUploadProgress={handleUploadProgress}
|
onUploadProgress={handleUploadProgress}
|
||||||
onUploadEnd={handleUploadEnd}
|
onUploadEnd={handleUploadEnd}
|
||||||
|
placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined}
|
||||||
/>
|
/>
|
||||||
{isPoll && (
|
{isPoll && (
|
||||||
<PollEditor
|
<PollEditor
|
||||||
@@ -332,7 +336,7 @@ export default function PostContent({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||||
{posting && <LoaderCircle className="animate-spin" />}
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
{parentStuff ? t('Reply') : t('Post')}
|
{parentStuff ? (highlightedText ? t('Publish Highlight') : t('Reply')) : t('Post')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,3 +370,62 @@ export default function PostContent({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createDraftEvent({
|
||||||
|
parentStuff,
|
||||||
|
text,
|
||||||
|
mentions,
|
||||||
|
isPoll,
|
||||||
|
pollCreateData,
|
||||||
|
pubkey,
|
||||||
|
addClientTag,
|
||||||
|
isProtectedEvent,
|
||||||
|
isNsfw,
|
||||||
|
highlightedText
|
||||||
|
}: {
|
||||||
|
parentStuff: Event | string | undefined
|
||||||
|
text: string
|
||||||
|
mentions: string[]
|
||||||
|
isPoll: boolean
|
||||||
|
pollCreateData: TPollCreateData
|
||||||
|
pubkey: string
|
||||||
|
addClientTag: boolean
|
||||||
|
isProtectedEvent: boolean
|
||||||
|
isNsfw: boolean
|
||||||
|
highlightedText?: string
|
||||||
|
}) {
|
||||||
|
const { parentEvent, externalContent } =
|
||||||
|
typeof parentStuff === 'string'
|
||||||
|
? { parentEvent: undefined, externalContent: parentStuff }
|
||||||
|
: { parentEvent: parentStuff, externalContent: undefined }
|
||||||
|
|
||||||
|
if (highlightedText && parentEvent) {
|
||||||
|
return createHighlightDraftEvent(highlightedText, text, parentEvent, mentions, {
|
||||||
|
addClientTag,
|
||||||
|
protectedEvent: isProtectedEvent,
|
||||||
|
isNsfw
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentStuff && (externalContent || parentEvent?.kind !== kinds.ShortTextNote)) {
|
||||||
|
return await createCommentDraftEvent(text, parentStuff, mentions, {
|
||||||
|
addClientTag,
|
||||||
|
protectedEvent: isProtectedEvent,
|
||||||
|
isNsfw
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPoll) {
|
||||||
|
return await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
|
||||||
|
addClientTag,
|
||||||
|
isNsfw
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createShortTextNoteDraftEvent(text, mentions, {
|
||||||
|
parentEvent,
|
||||||
|
addClientTag,
|
||||||
|
protectedEvent: isProtectedEvent,
|
||||||
|
isNsfw
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function PostOptions({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{t('Show others this was sent via Jumble')}
|
{t('Show others this was sent via Smesh')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const PostTextarea = forwardRef<
|
|||||||
onUploadStart?: (file: File, cancel: () => void) => void
|
onUploadStart?: (file: File, cancel: () => void) => void
|
||||||
onUploadProgress?: (file: File, progress: number) => void
|
onUploadProgress?: (file: File, progress: number) => void
|
||||||
onUploadEnd?: (file: File) => void
|
onUploadEnd?: (file: File) => void
|
||||||
|
placeholder?: string
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -52,7 +53,8 @@ const PostTextarea = forwardRef<
|
|||||||
className,
|
className,
|
||||||
onUploadStart,
|
onUploadStart,
|
||||||
onUploadProgress,
|
onUploadProgress,
|
||||||
onUploadEnd
|
onUploadEnd,
|
||||||
|
placeholder
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -67,6 +69,7 @@ const PostTextarea = forwardRef<
|
|||||||
HardBreak,
|
HardBreak,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder:
|
placeholder:
|
||||||
|
placeholder ??
|
||||||
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||||
}),
|
}),
|
||||||
Emoji.configure({
|
Emoji.configure({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
|||||||
import postEditor from '@/services/post-editor.service'
|
import postEditor from '@/services/post-editor.service'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { Dispatch, useMemo } from 'react'
|
import { Dispatch, useMemo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import PostContent from './PostContent'
|
import PostContent from './PostContent'
|
||||||
import Title from './Title'
|
import Title from './Title'
|
||||||
|
|
||||||
@@ -25,14 +26,17 @@ export default function PostEditor({
|
|||||||
parentStuff,
|
parentStuff,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
openFrom
|
openFrom,
|
||||||
|
highlightedText
|
||||||
}: {
|
}: {
|
||||||
defaultContent?: string
|
defaultContent?: string
|
||||||
parentStuff?: Event | string
|
parentStuff?: Event | string
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
openFrom?: string[]
|
openFrom?: string[]
|
||||||
|
highlightedText?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
@@ -42,9 +46,10 @@ export default function PostEditor({
|
|||||||
parentStuff={parentStuff}
|
parentStuff={parentStuff}
|
||||||
close={() => setOpen(false)}
|
close={() => setOpen(false)}
|
||||||
openFrom={openFrom}
|
openFrom={openFrom}
|
||||||
|
highlightedText={highlightedText}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}, [])
|
}, [highlightedText])
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +69,7 @@ export default function PostEditor({
|
|||||||
<div className="space-y-4 px-2 py-6">
|
<div className="space-y-4 px-2 py-6">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className="text-start">
|
<SheetTitle className="text-start">
|
||||||
<Title parentStuff={parentStuff} />
|
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="hidden" />
|
<SheetDescription className="hidden" />
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
@@ -92,7 +97,7 @@ export default function PostEditor({
|
|||||||
<div className="space-y-4 px-2 py-6">
|
<div className="space-y-4 px-2 py-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Title parentStuff={parentStuff} />
|
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="hidden" />
|
<DialogDescription className="hidden" />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ import {
|
|||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
parseContent
|
parseContent
|
||||||
} from '@/lib/content-parser'
|
} from '@/lib/content-parser'
|
||||||
import { detectLanguage } from '@/lib/utils'
|
|
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
|
||||||
import { TEmoji } from '@/types'
|
import { TEmoji } from '@/types'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded'
|
import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded'
|
||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
import ExternalLink from '../ExternalLink'
|
import ExternalLink from '../ExternalLink'
|
||||||
@@ -25,20 +21,10 @@ export default function ProfileAbout({
|
|||||||
emojis?: TEmoji[]
|
emojis?: TEmoji[]
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation()
|
|
||||||
const { translateText } = useTranslationService()
|
|
||||||
const needTranslation = useMemo(() => {
|
|
||||||
const detected = detectLanguage(about)
|
|
||||||
if (!detected) return false
|
|
||||||
if (detected === 'und') return true
|
|
||||||
return !i18n.language.startsWith(detected)
|
|
||||||
}, [about, i18n.language])
|
|
||||||
const [translatedAbout, setTranslatedAbout] = useState<string | null>(null)
|
|
||||||
const [translating, setTranslating] = useState(false)
|
|
||||||
const aboutNodes = useMemo(() => {
|
const aboutNodes = useMemo(() => {
|
||||||
if (!about) return null
|
if (!about) return null
|
||||||
|
|
||||||
const nodes = parseContent(translatedAbout ?? about, [
|
const nodes = parseContent(about, [
|
||||||
EmbeddedMentionParser,
|
EmbeddedMentionParser,
|
||||||
EmbeddedWebsocketUrlParser,
|
EmbeddedWebsocketUrlParser,
|
||||||
EmbeddedUrlParser,
|
EmbeddedUrlParser,
|
||||||
@@ -73,60 +59,7 @@ export default function ProfileAbout({
|
|||||||
}
|
}
|
||||||
return node.data
|
return node.data
|
||||||
})
|
})
|
||||||
}, [about, translatedAbout, emojis])
|
}, [about, emojis])
|
||||||
|
|
||||||
const handleTranslate = async () => {
|
return <div className={className}>{aboutNodes}</div>
|
||||||
if (translating || translatedAbout) return
|
|
||||||
setTranslating(true)
|
|
||||||
translateText(about ?? '')
|
|
||||||
.then((translated) => {
|
|
||||||
setTranslatedAbout(translated)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
'Translation failed: ' +
|
|
||||||
(error.message || 'An error occurred while translating the about')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setTranslating(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowOriginal = () => {
|
|
||||||
setTranslatedAbout(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={className}>{aboutNodes}</div>
|
|
||||||
{needTranslation && (
|
|
||||||
<div className="mt-2 text-sm">
|
|
||||||
{translating ? (
|
|
||||||
<div className="text-muted-foreground">{t('Translating...')}</div>
|
|
||||||
) : translatedAbout === null ? (
|
|
||||||
<button
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleTranslate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Translate')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleShowOriginal()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Show original')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
|
|
||||||
@@ -27,7 +26,10 @@ export default function ProfileBanner({
|
|||||||
<Image
|
<Image
|
||||||
image={{ url: bannerUrl, pubkey }}
|
image={{ url: bannerUrl, pubkey }}
|
||||||
alt={`${pubkey} banner`}
|
alt={`${pubkey} banner`}
|
||||||
className={cn('rounded-none', className)}
|
className={className}
|
||||||
|
classNames={{
|
||||||
|
wrapper: 'rounded-none'
|
||||||
|
}}
|
||||||
errorPlaceholder={defaultBanner}
|
errorPlaceholder={defaultBanner}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import QRCodeStyling from 'qr-code-styling'
|
import QRCodeStyling from 'qr-code-styling'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import iconSvg from '../../assets/favicon.svg'
|
import iconImg from '../../assets/smeshicondark.png'
|
||||||
|
|
||||||
export default function QrCode({ value, size = 180 }: { value: string; size?: number }) {
|
export default function QrCode({ value, size = 180 }: { value: string; size?: number }) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
@@ -13,7 +13,7 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu
|
|||||||
qrOptions: {
|
qrOptions: {
|
||||||
errorCorrectionLevel: 'M'
|
errorCorrectionLevel: 'M'
|
||||||
},
|
},
|
||||||
image: iconSvg,
|
image: iconImg,
|
||||||
width: size * pixelRatio,
|
width: size * pixelRatio,
|
||||||
height: size * pixelRatio,
|
height: size * pixelRatio,
|
||||||
data: value,
|
data: value,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import ContentPreview from '../ContentPreview'
|
|||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import Stars from '../Stars'
|
import Stars from '../Stars'
|
||||||
import TranslateButton from '../TranslateButton'
|
|
||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
import { SimpleUsername } from '../Username'
|
import { SimpleUsername } from '../Username'
|
||||||
|
|
||||||
@@ -46,9 +45,6 @@ export default function RelayReviewCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
|
||||||
<TranslateButton event={event} className="pr-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
|
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
|
||||||
<ContentPreview className="mt-2 line-clamp-4" event={event} />
|
<ContentPreview className="mt-2 line-clamp-4" event={event} />
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
|||||||
<div className="px-4 space-y-4">
|
<div className="px-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 justify-between">
|
<div className="flex items-center gap-2 justify-between">
|
||||||
<div className="flex gap-2 items-center truncate">
|
<div className="flex gap-2 items-center flex-1">
|
||||||
<RelayIcon url={url} className="w-8 h-8" />
|
<RelayIcon url={url} className="w-8 h-8" />
|
||||||
<div className="text-2xl font-semibold truncate select-text">
|
<div className="text-2xl font-semibold truncate select-text flex-1 w-0">
|
||||||
{relayInfo.name || relayInfo.shortUrl}
|
{relayInfo.name || relayInfo.shortUrl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@ function RelayControls({ url }: { url: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyShareableUrl = () => {
|
const handleCopyShareableUrl = () => {
|
||||||
navigator.clipboard.writeText(`https://jumble.social/?r=${url}`)
|
navigator.clipboard.writeText(`https://smesh.mleku.dev/?r=${url}`)
|
||||||
setCopiedShareableUrl(true)
|
setCopiedShareableUrl(true)
|
||||||
toast.success('Shareable URL copied to clipboard')
|
toast.success('Shareable URL copied to clipboard')
|
||||||
setTimeout(() => setCopiedShareableUrl(false), 2000)
|
setTimeout(() => setCopiedShareableUrl(false), 2000)
|
||||||
|
|||||||
@@ -32,7 +32,16 @@ export default function RelaySimpleInfo({
|
|||||||
</div>
|
</div>
|
||||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||||
</div>
|
</div>
|
||||||
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
|
{!!relayInfo?.description && (
|
||||||
|
<div
|
||||||
|
className="line-clamp-3 break-words whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
overflowWrap: 'anywhere'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relayInfo.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!!users?.length && (
|
{!!users?.length && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-muted-foreground">{t('Favorited by')} </div>
|
<div className="text-muted-foreground">{t('Favorited by')} </div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { isMentioningMutedUsers } from '@/lib/event'
|
import { useThread } from '@/hooks/useThread'
|
||||||
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -15,9 +18,9 @@ import Content from '../Content'
|
|||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import NoteOptions from '../NoteOptions'
|
import NoteOptions from '../NoteOptions'
|
||||||
import StuffStats from '../StuffStats'
|
|
||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import TranslateButton from '../TranslateButton'
|
import StuffStats from '../StuffStats'
|
||||||
|
import TrustScoreBadge from '../TrustScoreBadge'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
@@ -25,18 +28,23 @@ export default function ReplyNote({
|
|||||||
event,
|
event,
|
||||||
parentEventId,
|
parentEventId,
|
||||||
onClickParent = () => {},
|
onClickParent = () => {},
|
||||||
highlight = false
|
highlight = false,
|
||||||
|
className = ''
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
parentEventId?: string
|
parentEventId?: string
|
||||||
onClickParent?: () => void
|
onClickParent?: () => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
|
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||||
|
const replies = useThread(eventKey)
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
const show = useMemo(() => {
|
const show = useMemo(() => {
|
||||||
if (showMuted) {
|
if (showMuted) {
|
||||||
@@ -50,12 +58,35 @@ export default function ReplyNote({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
|
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
|
||||||
|
const hasReplies = useMemo(() => {
|
||||||
|
if (!replies || replies.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reply of replies) {
|
||||||
|
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (mutePubkeySet.has(reply.pubkey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, [replies])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`}
|
className={cn(
|
||||||
|
'relative pb-3 transition-colors duration-500 clickable',
|
||||||
|
highlight ? 'bg-primary/40' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
onClick={() => push(toNote(event))}
|
onClick={() => push(toNote(event))}
|
||||||
>
|
>
|
||||||
|
{hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
|
||||||
<Collapsible>
|
<Collapsible>
|
||||||
<div className="flex space-x-2 items-start px-4 pt-3">
|
<div className="flex space-x-2 items-start px-4 pt-3">
|
||||||
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
|
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
|
||||||
@@ -68,6 +99,7 @@ export default function ReplyNote({
|
|||||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||||
skeletonClassName="h-3"
|
skeletonClassName="h-3"
|
||||||
/>
|
/>
|
||||||
|
<TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
|
||||||
<ClientTag event={event} />
|
<ClientTag event={event} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
@@ -79,10 +111,7 @@ export default function ReplyNote({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center shrink-0">
|
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||||
<TranslateButton event={event} className="py-0" />
|
|
||||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{parentEventId && (
|
{parentEventId && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
|
|||||||
156
src/components/ReplyNoteList/SubReplies.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
|
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
||||||
|
import { toNote } from '@/lib/link'
|
||||||
|
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ReplyNote from '../ReplyNote'
|
||||||
|
|
||||||
|
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const allThreads = useAllDescendantThreads(parentKey)
|
||||||
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
|
const { mutePubkeySet } = useMuteList()
|
||||||
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const replies = useMemo(() => {
|
||||||
|
const replyKeySet = new Set<string>()
|
||||||
|
const replyEvents: NostrEvent[] = []
|
||||||
|
|
||||||
|
let parentKeys = [parentKey]
|
||||||
|
while (parentKeys.length > 0) {
|
||||||
|
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
|
||||||
|
events.forEach((evt) => {
|
||||||
|
const key = getEventKey(evt)
|
||||||
|
if (replyKeySet.has(key)) return
|
||||||
|
if (mutePubkeySet.has(evt.pubkey)) return
|
||||||
|
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
|
||||||
|
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||||
|
const replyKey = getEventKey(evt)
|
||||||
|
const repliesForThisReply = allThreads.get(replyKey)
|
||||||
|
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||||
|
if (
|
||||||
|
!repliesForThisReply ||
|
||||||
|
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyKeySet.add(key)
|
||||||
|
replyEvents.push(evt)
|
||||||
|
})
|
||||||
|
parentKeys = events.map((evt) => getEventKey(evt))
|
||||||
|
}
|
||||||
|
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||||
|
}, [
|
||||||
|
parentKey,
|
||||||
|
allThreads,
|
||||||
|
mutePubkeySet,
|
||||||
|
hideContentMentioningMutedUsers,
|
||||||
|
hideUntrustedInteractions
|
||||||
|
])
|
||||||
|
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
|
||||||
|
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
|
|
||||||
|
const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
|
||||||
|
let found = false
|
||||||
|
if (scrollTo) {
|
||||||
|
const ref = replyRefs.current[key]
|
||||||
|
if (ref) {
|
||||||
|
found = true
|
||||||
|
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
if (eventId) push(toNote(eventId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHighlightReplyKey(key)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
|
||||||
|
}, 1500)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (replies.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{replies.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsExpanded(!isExpanded)
|
||||||
|
}}
|
||||||
|
className="relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
|
||||||
|
style={{
|
||||||
|
background: isExpanded
|
||||||
|
? 'currentColor'
|
||||||
|
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{t('Hide replies')} ({replies.length})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{t('Show replies')} ({replies.length})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(isExpanded || replies.length === 1) && (
|
||||||
|
<div>
|
||||||
|
{replies.map((reply, index) => {
|
||||||
|
const currentReplyKey = getEventKey(reply)
|
||||||
|
const _parentTag = getParentTag(reply)
|
||||||
|
if (_parentTag?.type !== 'e') return null
|
||||||
|
const _parentKey = _parentTag ? getKeyFromTag(_parentTag.tag) : undefined
|
||||||
|
const _parentEventId = generateBech32IdFromETag(_parentTag.tag)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
|
||||||
|
key={currentReplyKey}
|
||||||
|
className="scroll-mt-12 flex relative"
|
||||||
|
>
|
||||||
|
<div className="absolute left-[34px] top-0 h-8 w-4 rounded-bl-lg border-l border-b z-20" />
|
||||||
|
{index < replies.length - 1 && (
|
||||||
|
<div className="absolute left-[34px] top-0 bottom-0 border-l z-20" />
|
||||||
|
)}
|
||||||
|
<ReplyNote
|
||||||
|
className="flex-1 w-0 pl-10"
|
||||||
|
event={reply}
|
||||||
|
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
||||||
|
onClickParent={() => {
|
||||||
|
if (!_parentKey) return
|
||||||
|
highlightReply(_parentKey, _parentEventId)
|
||||||
|
}}
|
||||||
|
highlight={highlightReplyKey === currentReplyKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,339 +1,118 @@
|
|||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||||
import { useStuff } from '@/hooks/useStuff'
|
import { useStuff } from '@/hooks/useStuff'
|
||||||
import {
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
getEventKey,
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
getKeyFromTag,
|
|
||||||
getParentTag,
|
|
||||||
getReplaceableCoordinateFromEvent,
|
|
||||||
getRootTag,
|
|
||||||
isMentioningMutedUsers,
|
|
||||||
isProtectedEvent,
|
|
||||||
isReplaceableEvent
|
|
||||||
} from '@/lib/event'
|
|
||||||
import { toNote } from '@/lib/link'
|
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import threadService from '@/services/thread.service'
|
||||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
import { Event as NEvent } from 'nostr-tools'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LoadingBar } from '../LoadingBar'
|
import { LoadingBar } from '../LoadingBar'
|
||||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||||
|
import SubReplies from './SubReplies'
|
||||||
type TRootInfo =
|
|
||||||
| { type: 'E'; id: string; pubkey: string }
|
|
||||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
|
||||||
| { type: 'I'; id: string }
|
|
||||||
|
|
||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
const SHOW_COUNT = 10
|
const SHOW_COUNT = 10
|
||||||
|
|
||||||
export default function ReplyNoteList({
|
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
||||||
stuff,
|
|
||||||
index
|
|
||||||
}: {
|
|
||||||
stuff: NEvent | string
|
|
||||||
index?: number
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push, currentIndex } = useSecondaryPage()
|
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
const { stuffKey } = useStuff(stuff)
|
||||||
const { repliesMap, addReplies } = useReply()
|
const allThreads = useAllDescendantThreads(stuffKey)
|
||||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
const [initialLoading, setInitialLoading] = useState(false)
|
||||||
|
|
||||||
const replies = useMemo(() => {
|
const replies = useMemo(() => {
|
||||||
const replyKeySet = new Set<string>()
|
const replyKeySet = new Set<string>()
|
||||||
const replyEvents: NEvent[] = []
|
const thread = allThreads.get(stuffKey) || []
|
||||||
|
const replyEvents = thread.filter((evt) => {
|
||||||
let parentKeys = [stuffKey]
|
const key = getEventKey(evt)
|
||||||
while (parentKeys.length > 0) {
|
if (replyKeySet.has(key)) return false
|
||||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
if (mutePubkeySet.has(evt.pubkey)) return false
|
||||||
events.forEach((evt) => {
|
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
|
||||||
const key = getEventKey(evt)
|
return false
|
||||||
if (replyKeySet.has(key)) return
|
|
||||||
if (mutePubkeySet.has(evt.pubkey)) return
|
|
||||||
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
|
|
||||||
|
|
||||||
replyKeySet.add(key)
|
|
||||||
replyEvents.push(evt)
|
|
||||||
})
|
|
||||||
parentKeys = events.map((evt) => getEventKey(evt))
|
|
||||||
}
|
|
||||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
|
||||||
}, [stuffKey, repliesMap])
|
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
|
||||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
|
||||||
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
|
|
||||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRootEvent = async () => {
|
|
||||||
if (!event && !externalContent) return
|
|
||||||
|
|
||||||
let root: TRootInfo = event
|
|
||||||
? isReplaceableEvent(event.kind)
|
|
||||||
? {
|
|
||||||
type: 'A',
|
|
||||||
id: getReplaceableCoordinateFromEvent(event),
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
relay: client.getEventHint(event.id)
|
|
||||||
}
|
|
||||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
|
||||||
: { type: 'I', id: externalContent! }
|
|
||||||
|
|
||||||
const rootTag = getRootTag(event)
|
|
||||||
if (rootTag?.type === 'e') {
|
|
||||||
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
|
|
||||||
if (rootEventHexId && rootEventPubkey) {
|
|
||||||
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
|
|
||||||
} else {
|
|
||||||
const rootEventId = generateBech32IdFromETag(rootTag.tag)
|
|
||||||
if (rootEventId) {
|
|
||||||
const rootEvent = await client.fetchEvent(rootEventId)
|
|
||||||
if (rootEvent) {
|
|
||||||
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (rootTag?.type === 'a') {
|
|
||||||
const [, coordinate, relay] = rootTag.tag
|
|
||||||
const [, pubkey] = coordinate.split(':')
|
|
||||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
|
||||||
} else if (rootTag?.type === 'i') {
|
|
||||||
root = { type: 'I', id: rootTag.tag[1] }
|
|
||||||
}
|
}
|
||||||
setRootInfo(root)
|
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||||
}
|
const replyKey = getEventKey(evt)
|
||||||
fetchRootEvent()
|
const repliesForThisReply = allThreads.get(replyKey)
|
||||||
}, [event])
|
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||||
|
if (
|
||||||
useEffect(() => {
|
!repliesForThisReply ||
|
||||||
if (loading || !rootInfo || currentIndex !== index) return
|
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||||
|
) {
|
||||||
const init = async () => {
|
return false
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
let relayUrls: string[] = []
|
|
||||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
|
||||||
if (rootPubkey) {
|
|
||||||
const relayList = await client.fetchRelayList(rootPubkey)
|
|
||||||
relayUrls = relayList.read
|
|
||||||
}
|
}
|
||||||
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
|
|
||||||
|
|
||||||
// If current event is protected, we can assume its replies are also protected and stored on the same relays
|
|
||||||
if (event && isProtectedEvent(event)) {
|
|
||||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
|
||||||
relayUrls.concat(...seenOn)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters: (Omit<Filter, 'since' | 'until'> & {
|
|
||||||
limit: number
|
|
||||||
})[] = []
|
|
||||||
if (rootInfo.type === 'E') {
|
|
||||||
filters.push({
|
|
||||||
'#e': [rootInfo.id],
|
|
||||||
kinds: [kinds.ShortTextNote],
|
|
||||||
limit: LIMIT
|
|
||||||
})
|
|
||||||
if (event?.kind !== kinds.ShortTextNote) {
|
|
||||||
filters.push({
|
|
||||||
'#E': [rootInfo.id],
|
|
||||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
|
||||||
limit: LIMIT
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (rootInfo.type === 'A') {
|
|
||||||
filters.push(
|
|
||||||
{
|
|
||||||
'#a': [rootInfo.id],
|
|
||||||
kinds: [kinds.ShortTextNote],
|
|
||||||
limit: LIMIT
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'#A': [rootInfo.id],
|
|
||||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
|
||||||
limit: LIMIT
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (rootInfo.relay) {
|
|
||||||
relayUrls.push(rootInfo.relay)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filters.push({
|
|
||||||
'#I': [rootInfo.id],
|
|
||||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
|
||||||
limit: LIMIT
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
|
||||||
filters.map((filter) => ({
|
|
||||||
urls: relayUrls.slice(0, 8),
|
|
||||||
filter
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
onEvents: (evts, eosed) => {
|
|
||||||
if (evts.length > 0) {
|
|
||||||
addReplies(evts)
|
|
||||||
}
|
|
||||||
if (eosed) {
|
|
||||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNew: (evt) => {
|
|
||||||
addReplies([evt])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
setTimelineKey(timelineKey)
|
|
||||||
return closer
|
|
||||||
} catch {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = init()
|
replyKeySet.add(key)
|
||||||
return () => {
|
return true
|
||||||
promise.then((closer) => closer?.())
|
})
|
||||||
}
|
return replyEvents.sort((a, b) => b.created_at - a.created_at)
|
||||||
}, [rootInfo, currentIndex, index])
|
}, [
|
||||||
|
stuffKey,
|
||||||
|
allThreads,
|
||||||
|
mutePubkeySet,
|
||||||
|
hideContentMentioningMutedUsers,
|
||||||
|
hideUntrustedInteractions,
|
||||||
|
isUserTrusted
|
||||||
|
])
|
||||||
|
|
||||||
|
// Initial subscription
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replies.length === 0) {
|
const loadInitial = async () => {
|
||||||
loadMore()
|
setInitialLoading(true)
|
||||||
}
|
await threadService.subscribe(stuff, LIMIT)
|
||||||
}, [replies])
|
setInitialLoading(false)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const options = {
|
|
||||||
root: null,
|
|
||||||
rootMargin: '10px',
|
|
||||||
threshold: 0.1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => {
|
loadInitial()
|
||||||
if (entries[0].isIntersecting && showCount < replies.length) {
|
|
||||||
setShowCount((prev) => prev + SHOW_COUNT)
|
|
||||||
}
|
|
||||||
}, options)
|
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current
|
|
||||||
|
|
||||||
if (currentBottomRef) {
|
|
||||||
observerInstance.observe(currentBottomRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (observerInstance && currentBottomRef) {
|
threadService.unsubscribe(stuff)
|
||||||
observerInstance.unobserve(currentBottomRef)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [replies, showCount])
|
}, [stuff])
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
if (loading || !until || !timelineKey) return
|
return await threadService.loadMore(stuff, LIMIT)
|
||||||
|
}, [stuff])
|
||||||
|
|
||||||
setLoading(true)
|
const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
items: replies,
|
||||||
addReplies(events)
|
showCount: SHOW_COUNT,
|
||||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
onLoadMore: handleLoadMore,
|
||||||
setLoading(false)
|
initialLoading
|
||||||
}, [loading, until, timelineKey])
|
})
|
||||||
|
|
||||||
const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
|
|
||||||
let found = false
|
|
||||||
if (scrollTo) {
|
|
||||||
const ref = replyRefs.current[key]
|
|
||||||
if (ref) {
|
|
||||||
found = true
|
|
||||||
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
if (eventId) push(toNote(eventId))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setHighlightReplyKey(key)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
|
|
||||||
}, 1500)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[80vh]">
|
<div className="min-h-[80vh]">
|
||||||
{loading && <LoadingBar />}
|
{(loading || initialLoading) && <LoadingBar />}
|
||||||
{!loading && until && (!event || until > event.created_at) && (
|
|
||||||
<div
|
|
||||||
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
|
||||||
onClick={loadMore}
|
|
||||||
>
|
|
||||||
{t('load more older replies')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
{replies.slice(0, showCount).map((reply) => {
|
{visibleItems.map((reply) => (
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
<Item key={reply.id} reply={reply} />
|
||||||
const replyKey = getEventKey(reply)
|
))}
|
||||||
const repliesForThisReply = repliesMap.get(replyKey)
|
|
||||||
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
|
||||||
if (
|
|
||||||
!repliesForThisReply ||
|
|
||||||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootKey = event ? getEventKey(event) : externalContent!
|
|
||||||
const currentReplyKey = getEventKey(reply)
|
|
||||||
const parentTag = getParentTag(reply)
|
|
||||||
const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
|
|
||||||
const parentEventId = parentTag
|
|
||||||
? parentTag.type === 'e'
|
|
||||||
? generateBech32IdFromETag(parentTag.tag)
|
|
||||||
: parentTag.type === 'a'
|
|
||||||
? generateBech32IdFromATag(parentTag.tag)
|
|
||||||
: undefined
|
|
||||||
: undefined
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
|
|
||||||
key={currentReplyKey}
|
|
||||||
className="scroll-mt-12"
|
|
||||||
>
|
|
||||||
<ReplyNote
|
|
||||||
event={reply}
|
|
||||||
parentEventId={rootKey !== parentKey ? parentEventId : undefined}
|
|
||||||
onClickParent={() => {
|
|
||||||
if (!parentKey) return
|
|
||||||
highlightReply(parentKey, parentEventId)
|
|
||||||
}}
|
|
||||||
highlight={highlightReplyKey === currentReplyKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{!loading && (
|
<div ref={bottomRef} />
|
||||||
|
{shouldShowLoadingIndicator ? (
|
||||||
|
<ReplyNoteSkeleton />
|
||||||
|
) : (
|
||||||
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
||||||
{replies.length > 0 ? t('no more replies') : t('no replies')}
|
{replies.length > 0 ? t('no more replies') : t('no replies')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
</div>
|
||||||
{loading && <ReplyNoteSkeleton />}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({ reply }: { reply: NEvent }) {
|
||||||
|
const key = useMemo(() => getEventKey(reply), [reply])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative border-b">
|
||||||
|
<ReplyNote event={reply} />
|
||||||
|
<SubReplies parentKey={key} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +1,586 @@
|
|||||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||||
import Donation from '@/components/Donation'
|
import Donation from '@/components/Donation'
|
||||||
|
import Emoji from '@/components/Emoji'
|
||||||
|
import EmojiPackList from '@/components/EmojiPackList'
|
||||||
|
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
||||||
|
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
||||||
|
import MailboxSetting from '@/components/MailboxSetting'
|
||||||
|
import NoteList from '@/components/NoteList'
|
||||||
|
import Tabs from '@/components/Tabs'
|
||||||
import {
|
import {
|
||||||
toAppearanceSettings,
|
Accordion,
|
||||||
toEmojiPackSettings,
|
AccordionContent,
|
||||||
toGeneralSettings,
|
AccordionItem,
|
||||||
toPostSettings,
|
AccordionTrigger
|
||||||
toRelaySettings,
|
} from '@/components/ui/accordion'
|
||||||
toSystemSettings,
|
import {
|
||||||
toTranslation,
|
AlertDialog,
|
||||||
toWallet
|
AlertDialogAction,
|
||||||
} from '@/lib/link'
|
AlertDialogCancel,
|
||||||
import { cn } from '@/lib/utils'
|
AlertDialogContent,
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs as RadixTabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
BIG_RELAY_URLS,
|
||||||
|
DEFAULT_FAVICON_URL_TEMPLATE,
|
||||||
|
MEDIA_AUTO_LOAD_POLICY,
|
||||||
|
NSFW_DISPLAY_POLICY,
|
||||||
|
PRIMARY_COLORS,
|
||||||
|
TPrimaryColor
|
||||||
|
} from '@/constants'
|
||||||
|
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
|
||||||
|
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
|
||||||
|
import MediaUploadServiceSetting from '@/pages/secondary/PostSettingsPage/MediaUploadServiceSetting'
|
||||||
|
import DefaultZapAmountInput from '@/pages/secondary/WalletPage/DefaultZapAmountInput'
|
||||||
|
import DefaultZapCommentInput from '@/pages/secondary/WalletPage/DefaultZapCommentInput'
|
||||||
|
import LightningAddressInput from '@/pages/secondary/WalletPage/LightningAddressInput'
|
||||||
|
import QuickZapSwitch from '@/pages/secondary/WalletPage/QuickZapSwitch'
|
||||||
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useTheme } from '@/providers/ThemeProvider'
|
||||||
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
|
import { useZap } from '@/providers/ZapProvider'
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
|
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
||||||
|
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
|
||||||
Cog,
|
Cog,
|
||||||
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
Info,
|
Info,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Languages,
|
LayoutList,
|
||||||
|
List,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
|
PanelLeft,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
|
RotateCcw,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
Smile,
|
Smile,
|
||||||
|
Sun,
|
||||||
Wallet
|
Wallet
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { forwardRef, HTMLProps, useState } from 'react'
|
import { kinds } from 'nostr-tools'
|
||||||
|
import { forwardRef, HTMLProps, useCallback, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type TEmojiTab = 'my-packs' | 'explore'
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{ key: 'system', label: 'System', icon: <Monitor className="size-5" /> },
|
||||||
|
{ key: 'light', label: 'Light', icon: <Sun className="size-5" /> },
|
||||||
|
{ key: 'dark', label: 'Dark', icon: <Moon className="size-5" /> },
|
||||||
|
{ key: 'pure-black', label: 'Pure Black', icon: <Moon className="size-5 fill-current" /> }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const LAYOUTS = [
|
||||||
|
{ key: false, label: 'Two-column', icon: <Columns2 className="size-5" /> },
|
||||||
|
{ key: true, label: 'Single-column', icon: <PanelLeft className="size-5" /> }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const NOTIFICATION_STYLES = [
|
||||||
|
{ key: 'detailed', label: 'Detailed', icon: <LayoutList className="size-5" /> },
|
||||||
|
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
||||||
|
] as const
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { pubkey, nsec, ncryptsec } = useNostr()
|
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||||
const { push } = useSecondaryPage()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
||||||
|
const [openSection, setOpenSection] = useState<string>('')
|
||||||
|
const accordionRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// General settings
|
||||||
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
|
const {
|
||||||
|
autoplay,
|
||||||
|
setAutoplay,
|
||||||
|
nsfwDisplayPolicy,
|
||||||
|
setNsfwDisplayPolicy,
|
||||||
|
hideContentMentioningMutedUsers,
|
||||||
|
setHideContentMentioningMutedUsers,
|
||||||
|
mediaAutoLoadPolicy,
|
||||||
|
setMediaAutoLoadPolicy,
|
||||||
|
faviconUrlTemplate,
|
||||||
|
setFaviconUrlTemplate
|
||||||
|
} = useContentPolicy()
|
||||||
|
const {
|
||||||
|
hideUntrustedNotes,
|
||||||
|
updateHideUntrustedNotes,
|
||||||
|
hideUntrustedInteractions,
|
||||||
|
updateHideUntrustedInteractions,
|
||||||
|
hideUntrustedNotifications,
|
||||||
|
updateHideUntrustedNotifications
|
||||||
|
} = useUserTrust()
|
||||||
|
const {
|
||||||
|
quickReaction,
|
||||||
|
updateQuickReaction,
|
||||||
|
quickReactionEmoji,
|
||||||
|
updateQuickReactionEmoji,
|
||||||
|
enableSingleColumnLayout,
|
||||||
|
updateEnableSingleColumnLayout,
|
||||||
|
notificationListStyle,
|
||||||
|
updateNotificationListStyle
|
||||||
|
} = useUserPreferences()
|
||||||
|
|
||||||
|
// Appearance settings
|
||||||
|
const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
|
||||||
|
|
||||||
|
// Wallet settings
|
||||||
|
const { isWalletConnected, walletInfo } = useZap()
|
||||||
|
|
||||||
|
// Relay settings
|
||||||
|
const [relayTabValue, setRelayTabValue] = useState('favorite-relays')
|
||||||
|
|
||||||
|
// Emoji settings
|
||||||
|
const [emojiTab, setEmojiTab] = useState<TEmojiTab>('my-packs')
|
||||||
|
|
||||||
|
// System settings
|
||||||
|
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
||||||
|
|
||||||
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
|
i18n.changeLanguage(value)
|
||||||
|
setLanguage(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccordionChange = useCallback((value: string) => {
|
||||||
|
setOpenSection(value)
|
||||||
|
if (value) {
|
||||||
|
// Scroll the opened section into view
|
||||||
|
setTimeout(() => {
|
||||||
|
const item = accordionRef.current?.querySelector(`[data-state="open"]`)
|
||||||
|
if (item) {
|
||||||
|
const rect = item.getBoundingClientRect()
|
||||||
|
const scrollContainer = accordionRef.current?.closest('[data-radix-scroll-area-viewport]') || window
|
||||||
|
if (scrollContainer === window) {
|
||||||
|
const scrollTop = window.scrollY + rect.top - 16
|
||||||
|
window.scrollTo({ top: scrollTop, behavior: 'smooth' })
|
||||||
|
} else {
|
||||||
|
const containerRect = (scrollContainer as HTMLElement).getBoundingClientRect()
|
||||||
|
const scrollTop = (scrollContainer as HTMLElement).scrollTop + rect.top - containerRect.top - 16
|
||||||
|
;(scrollContainer as HTMLElement).scrollTo({ top: scrollTop, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div ref={accordionRef}>
|
||||||
<SettingItem className="clickable" onClick={() => push(toGeneralSettings())}>
|
<Accordion
|
||||||
<div className="flex items-center gap-4">
|
type="single"
|
||||||
<Settings2 />
|
collapsible
|
||||||
<div>{t('General')}</div>
|
value={openSection}
|
||||||
</div>
|
onValueChange={handleAccordionChange}
|
||||||
<ChevronRight />
|
className="w-full"
|
||||||
</SettingItem>
|
>
|
||||||
<SettingItem className="clickable" onClick={() => push(toAppearanceSettings())}>
|
{/* General */}
|
||||||
<div className="flex items-center gap-4">
|
<AccordionItem value="general">
|
||||||
<Palette />
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div>{t('Appearance')}</div>
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
<Settings2 className="size-4" />
|
||||||
<ChevronRight />
|
<span>{t('General')}</span>
|
||||||
</SettingItem>
|
</div>
|
||||||
<SettingItem className="clickable" onClick={() => push(toRelaySettings())}>
|
</AccordionTrigger>
|
||||||
<div className="flex items-center gap-4">
|
<AccordionContent className="px-4 space-y-4">
|
||||||
<Server />
|
<SettingItem>
|
||||||
<div>{t('Relays')}</div>
|
<Label htmlFor="languages" className="text-base font-normal">
|
||||||
</div>
|
{t('Languages')}
|
||||||
<ChevronRight />
|
</Label>
|
||||||
</SettingItem>
|
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
|
||||||
{!!pubkey && (
|
<SelectTrigger id="languages" className="w-48">
|
||||||
<SettingItem className="clickable" onClick={() => push(toTranslation())}>
|
<SelectValue />
|
||||||
<div className="flex items-center gap-4">
|
</SelectTrigger>
|
||||||
<Languages />
|
<SelectContent>
|
||||||
<div>{t('Translation')}</div>
|
{Object.entries(LocalizedLanguageNames).map(([key, value]) => (
|
||||||
</div>
|
<SelectItem key={key} value={key}>
|
||||||
<ChevronRight />
|
{value}
|
||||||
</SettingItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
{!!pubkey && (
|
</SelectContent>
|
||||||
<SettingItem className="clickable" onClick={() => push(toWallet())}>
|
</Select>
|
||||||
<div className="flex items-center gap-4">
|
</SettingItem>
|
||||||
<Wallet />
|
<SettingItem>
|
||||||
<div>{t('Wallet')}</div>
|
<Label htmlFor="media-auto-load-policy" className="text-base font-normal">
|
||||||
</div>
|
{t('Auto-load media')}
|
||||||
<ChevronRight />
|
</Label>
|
||||||
</SettingItem>
|
<Select
|
||||||
)}
|
defaultValue="wifi-only"
|
||||||
{!!pubkey && (
|
value={mediaAutoLoadPolicy}
|
||||||
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
|
onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
|
||||||
<div className="flex items-center gap-4">
|
>
|
||||||
<PencilLine />
|
<SelectTrigger id="media-auto-load-policy" className="w-48">
|
||||||
<div>{t('Post settings')}</div>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
<ChevronRight />
|
<SelectContent>
|
||||||
</SettingItem>
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
|
||||||
)}
|
{isSupportCheckConnectionType() && (
|
||||||
{!!pubkey && (
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
|
||||||
<SettingItem className="clickable" onClick={() => push(toEmojiPackSettings())}>
|
)}
|
||||||
<div className="flex items-center gap-4">
|
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
|
||||||
<Smile />
|
</SelectContent>
|
||||||
<div>{t('Emoji Packs')}</div>
|
</Select>
|
||||||
</div>
|
</SettingItem>
|
||||||
<ChevronRight />
|
<SettingItem>
|
||||||
</SettingItem>
|
<Label htmlFor="autoplay" className="text-base font-normal">
|
||||||
)}
|
<div>{t('Autoplay')}</div>
|
||||||
|
<div className="text-muted-foreground">{t('Enable video autoplay on this device')}</div>
|
||||||
|
</Label>
|
||||||
|
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
|
||||||
|
{t('Hide untrusted notes')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="hide-untrusted-notes"
|
||||||
|
checked={hideUntrustedNotes}
|
||||||
|
onCheckedChange={updateHideUntrustedNotes}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
|
||||||
|
{t('Hide untrusted interactions')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="hide-untrusted-interactions"
|
||||||
|
checked={hideUntrustedInteractions}
|
||||||
|
onCheckedChange={updateHideUntrustedInteractions}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
|
||||||
|
{t('Hide untrusted notifications')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="hide-untrusted-notifications"
|
||||||
|
checked={hideUntrustedNotifications}
|
||||||
|
onCheckedChange={updateHideUntrustedNotifications}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
|
||||||
|
{t('Hide content mentioning muted users')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="hide-content-mentioning-muted-users"
|
||||||
|
checked={hideContentMentioningMutedUsers}
|
||||||
|
onCheckedChange={setHideContentMentioningMutedUsers}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="nsfw-display-policy" className="text-base font-normal">
|
||||||
|
{t('NSFW content display')}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={nsfwDisplayPolicy}
|
||||||
|
onValueChange={(value: TNsfwDisplayPolicy) => setNsfwDisplayPolicy(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="nsfw-display-policy" className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE}>{t('Hide completely')}</SelectItem>
|
||||||
|
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE_CONTENT}>{t('Show but hide content')}</SelectItem>
|
||||||
|
<SelectItem value={NSFW_DISPLAY_POLICY.SHOW}>{t('Show directly')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="quick-reaction" className="text-base font-normal">
|
||||||
|
<div>{t('Quick reaction')}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t('If enabled, you can react with a single click. Click and hold for more options')}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Switch id="quick-reaction" checked={quickReaction} onCheckedChange={updateQuickReaction} />
|
||||||
|
</SettingItem>
|
||||||
|
{quickReaction && (
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="quick-reaction-emoji" className="text-base font-normal">
|
||||||
|
{t('Quick reaction emoji')}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => updateQuickReactionEmoji('+')}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
<EmojiPickerDialog
|
||||||
|
onEmojiClick={(emoji) => {
|
||||||
|
if (!emoji) return
|
||||||
|
updateQuickReactionEmoji(emoji)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" className="border">
|
||||||
|
<Emoji emoji={quickReactionEmoji} />
|
||||||
|
</Button>
|
||||||
|
</EmojiPickerDialog>
|
||||||
|
</div>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<AccordionItem value="appearance">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Palette className="size-4" />
|
||||||
|
<span>{t('Appearance')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">{t('Theme')}</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
|
||||||
|
{THEMES.map(({ key, label, icon }) => (
|
||||||
|
<OptionButton
|
||||||
|
key={key}
|
||||||
|
isSelected={themeSetting === key}
|
||||||
|
icon={icon}
|
||||||
|
label={t(label)}
|
||||||
|
onClick={() => setThemeSetting(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isSmallScreen && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">{t('Layout')}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4 w-full">
|
||||||
|
{LAYOUTS.map(({ key, label, icon }) => (
|
||||||
|
<OptionButton
|
||||||
|
key={key.toString()}
|
||||||
|
isSelected={enableSingleColumnLayout === key}
|
||||||
|
icon={icon}
|
||||||
|
label={t(label)}
|
||||||
|
onClick={() => updateEnableSingleColumnLayout(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">{t('Notification list style')}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4 w-full">
|
||||||
|
{NOTIFICATION_STYLES.map(({ key, label, icon }) => (
|
||||||
|
<OptionButton
|
||||||
|
key={key}
|
||||||
|
isSelected={notificationListStyle === key}
|
||||||
|
icon={icon}
|
||||||
|
label={t(label)}
|
||||||
|
onClick={() => updateNotificationListStyle(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-base">{t('Primary color')}</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-4 w-full">
|
||||||
|
{Object.entries(PRIMARY_COLORS).map(([key, config]) => (
|
||||||
|
<OptionButton
|
||||||
|
key={key}
|
||||||
|
isSelected={primaryColor === key}
|
||||||
|
icon={
|
||||||
|
<div
|
||||||
|
className="size-8 rounded-full shadow-md"
|
||||||
|
style={{ backgroundColor: `hsl(${config.light.primary})` }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t(config.name)}
|
||||||
|
onClick={() => setPrimaryColor(key as TPrimaryColor)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Relays */}
|
||||||
|
<AccordionItem value="relays">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Server className="size-4" />
|
||||||
|
<span>{t('Relays')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<RadixTabs value={relayTabValue} onValueChange={setRelayTabValue} className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="favorite-relays">
|
||||||
|
<FavoriteRelaysSetting />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mailbox">
|
||||||
|
<MailboxSetting />
|
||||||
|
</TabsContent>
|
||||||
|
</RadixTabs>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Wallet */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<AccordionItem value="wallet">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Wallet className="size-4" />
|
||||||
|
<span>{t('Wallet')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 space-y-4">
|
||||||
|
{isWalletConnected ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{walletInfo?.node.alias && (
|
||||||
|
<div className="mb-2">
|
||||||
|
{t('Connected to')} <strong>{walletInfo.node.alias}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">{t('Disconnect Wallet')}</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('You will not be able to send zaps to others.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={() => disconnect()}>
|
||||||
|
{t('Disconnect')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
<DefaultZapAmountInput />
|
||||||
|
<DefaultZapCommentInput />
|
||||||
|
<QuickZapSwitch />
|
||||||
|
<LightningAddressInput />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
||||||
|
{t('Connect Wallet')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Post Settings */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<AccordionItem value="posts">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<PencilLine className="size-4" />
|
||||||
|
<span>{t('Post settings')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<MediaUploadServiceSetting />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Emoji Packs */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<AccordionItem value="emoji-packs">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Smile className="size-4" />
|
||||||
|
<span>{t('Emoji Packs')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<Tabs
|
||||||
|
value={emojiTab}
|
||||||
|
tabs={[
|
||||||
|
{ value: 'my-packs', label: 'My Packs' },
|
||||||
|
{ value: 'explore', label: 'Explore' }
|
||||||
|
]}
|
||||||
|
onTabChange={(tab) => setEmojiTab(tab as TEmojiTab)}
|
||||||
|
/>
|
||||||
|
{emojiTab === 'my-packs' ? (
|
||||||
|
<EmojiPackList />
|
||||||
|
) : (
|
||||||
|
<NoteList
|
||||||
|
showKinds={[kinds.Emojisets]}
|
||||||
|
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
|
||||||
|
hideUntrustedNotes={hideUntrustedNotes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System */}
|
||||||
|
<AccordionItem value="system">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Cog className="size-4" />
|
||||||
|
<span>{t('System')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="favicon-url" className="text-base font-normal">
|
||||||
|
{t('Favicon URL')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="favicon-url"
|
||||||
|
type="text"
|
||||||
|
value={faviconUrlTemplate}
|
||||||
|
onChange={(e) => setFaviconUrlTemplate(e.target.value)}
|
||||||
|
placeholder={DEFAULT_FAVICON_URL_TEMPLATE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="filter-out-onion-relays" className="text-base font-normal">
|
||||||
|
{t('Filter out onion relays')}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="filter-out-onion-relays"
|
||||||
|
checked={filterOutOnionRelays}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
storage.setFilterOutOnionRelays(checked)
|
||||||
|
setFilterOutOnionRelays(checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Non-accordion items */}
|
||||||
{!!nsec && (
|
{!!nsec && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
className="clickable"
|
className="clickable"
|
||||||
@@ -129,13 +613,6 @@ export default function Settings() {
|
|||||||
{copiedNcryptsec ? <Check /> : <Copy />}
|
{copiedNcryptsec ? <Check /> : <Copy />}
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
)}
|
)}
|
||||||
<SettingItem className="clickable" onClick={() => push(toSystemSettings())}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Cog />
|
|
||||||
<div>{t('System')}</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight />
|
|
||||||
</SettingItem>
|
|
||||||
<AboutInfoDialog>
|
<AboutInfoDialog>
|
||||||
<SettingItem className="clickable">
|
<SettingItem className="clickable">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -146,7 +623,6 @@ export default function Settings() {
|
|||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
|
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight />
|
|
||||||
</div>
|
</div>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</AboutInfoDialog>
|
</AboutInfoDialog>
|
||||||
@@ -162,7 +638,7 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-between select-none items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0',
|
'flex justify-between select-none items-center px-4 min-h-9 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -174,3 +650,28 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
SettingItem.displayName = 'SettingItem'
|
SettingItem.displayName = 'SettingItem'
|
||||||
|
|
||||||
|
const OptionButton = ({
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
label
|
||||||
|
}: {
|
||||||
|
isSelected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
|
||||||
|
isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useStuff } from '@/hooks/useStuff'
|
import { useStuff } from '@/hooks/useStuff'
|
||||||
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey, checkLogin } = useNostr()
|
const { pubkey, checkLogin } = useNostr()
|
||||||
const { event, stuffKey } = useStuff(stuff)
|
const { event, stuffKey } = useStuff(stuff)
|
||||||
const { repliesMap } = useReply()
|
const allThreads = useAllDescendantThreads(stuffKey)
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { replyCount, hasReplied } = useMemo(() => {
|
const { replyCount, hasReplied } = useMemo(() => {
|
||||||
const hasReplied = pubkey
|
const hasReplied = pubkey
|
||||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
let replyCount = 0
|
let replyCount = 0
|
||||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
const replies = [...(allThreads.get(stuffKey) ?? [])]
|
||||||
while (replies.length > 0) {
|
while (replies.length > 0) {
|
||||||
const reply = replies.pop()
|
const reply = replies.pop()
|
||||||
if (!reply) break
|
if (!reply) break
|
||||||
|
|
||||||
const replyKey = getEventKey(reply)
|
const replyKey = getEventKey(reply)
|
||||||
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
|
const nestedReplies = allThreads.get(replyKey) ?? []
|
||||||
replies.push(...nestedReplies)
|
replies.push(...nestedReplies)
|
||||||
|
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||||
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { replyCount, hasReplied }
|
return { replyCount, hasReplied }
|
||||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
|
|||||||
const dismissed = storage.getDismissedTooManyRelaysAlert()
|
const dismissed = storage.getDismissedTooManyRelaysAlert()
|
||||||
if (dismissed) return
|
if (dismissed) return
|
||||||
|
|
||||||
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) {
|
if (relayList && (relayList.read.length > 5 || relayList.write.length > 5)) {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
} else {
|
} else {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { ExtendedKind } from '@/constants'
|
|
||||||
import { useTranslatedEvent } from '@/hooks'
|
|
||||||
import { toTranslation } from '@/lib/link'
|
|
||||||
import { cn, detectLanguage } from '@/lib/utils'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
|
||||||
import { Languages, Loader } from 'lucide-react'
|
|
||||||
import { Event, kinds } from 'nostr-tools'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
export default function TranslateButton({
|
|
||||||
event,
|
|
||||||
className
|
|
||||||
}: {
|
|
||||||
event: Event
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { i18n } = useTranslation()
|
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
const { translateEvent, showOriginalEvent } = useTranslationService()
|
|
||||||
const [translating, setTranslating] = useState(false)
|
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
|
||||||
const supported = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
kinds.ShortTextNote,
|
|
||||||
kinds.Highlights,
|
|
||||||
ExtendedKind.COMMENT,
|
|
||||||
ExtendedKind.PICTURE,
|
|
||||||
ExtendedKind.POLL,
|
|
||||||
ExtendedKind.RELAY_REVIEW
|
|
||||||
].includes(event.kind),
|
|
||||||
[event]
|
|
||||||
)
|
|
||||||
|
|
||||||
const needTranslation = useMemo(() => {
|
|
||||||
const detected = detectLanguage(event.content)
|
|
||||||
if (!detected) return false
|
|
||||||
if (detected === 'und') return true
|
|
||||||
return !i18n.language.startsWith(detected)
|
|
||||||
}, [event, i18n.language])
|
|
||||||
|
|
||||||
if (!supported || !needTranslation) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTranslate = async () => {
|
|
||||||
if (translating) return
|
|
||||||
|
|
||||||
setTranslating(true)
|
|
||||||
await translateEvent(event)
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
'Translation failed: ' + (error.message || 'An error occurred while translating the note')
|
|
||||||
)
|
|
||||||
if (error.message === 'Insufficient balance.') {
|
|
||||||
push(toTranslation())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setTranslating(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showOriginal = () => {
|
|
||||||
showOriginalEvent(event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex items-center text-muted-foreground hover:text-pink-400 px-2 py-1 h-full disabled:text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 transition-colors',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={translating}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (translatedEvent) {
|
|
||||||
showOriginal()
|
|
||||||
} else {
|
|
||||||
handleTranslate()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{translating ? (
|
|
||||||
<Loader className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Languages className={translatedEvent ? 'text-pink-400 hover:text-pink-400/60' : ''} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
|||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
||||||
import { TFeedSubRequest } from '@/types'
|
import { TFeedSubRequest } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
|
|||||||
const { pinnedPubkeySet } = usePinnedUsers()
|
const { pinnedPubkeySet } = usePinnedUsers()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
|
|||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setHasMore(events.length > 0)
|
setHasMore(events.length > 0)
|
||||||
addReplies(events)
|
threadService.addRepliesToThread(events)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
setNewEvents((oldEvents) =>
|
setNewEvents((oldEvents) =>
|
||||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
)
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
},
|
},
|
||||||
onClose: (url, reason) => {
|
onClose: (url, reason) => {
|
||||||
if (!showRelayCloseReason) return
|
if (!showRelayCloseReason) return
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ export default function WebPreview({
|
|||||||
{image && (
|
{image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: image }}
|
image={{ url: image }}
|
||||||
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 rounded-none border-r"
|
className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
|
||||||
classNames={{
|
classNames={{
|
||||||
skeleton: 'rounded-none border-r'
|
wrapper: 'rounded-none border-r'
|
||||||
}}
|
}}
|
||||||
hideIfError
|
hideIfError
|
||||||
/>
|
/>
|
||||||
|
|||||||
51
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
AccordionItem.displayName = 'AccordionItem'
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
))
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
))
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
@@ -25,7 +25,7 @@ const buttonVariants = cva(
|
|||||||
default: 'h-9 px-4 py-2',
|
default: 'h-9 px-4 py-2',
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
lg: 'h-10 rounded-lg px-8',
|
lg: 'h-10 rounded-lg px-8',
|
||||||
icon: 'h-9 w-9',
|
icon: 'h-9 w-9 shrink-0',
|
||||||
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
|
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
|
|
||||||
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
|
export const SMESH_API_BASE_URL = 'https://api.smesh.social'
|
||||||
|
|
||||||
export const RECOMMENDED_BLOSSOM_SERVERS = [
|
export const RECOMMENDED_BLOSSOM_SERVERS = [
|
||||||
'https://blossom.band/',
|
'https://blossom.band/',
|
||||||
@@ -62,12 +62,17 @@ export const ApplicationDataKey = {
|
|||||||
|
|
||||||
export const BIG_RELAY_URLS = [
|
export const BIG_RELAY_URLS = [
|
||||||
'wss://relay.damus.io/',
|
'wss://relay.damus.io/',
|
||||||
'wss://relay.nostr.band/',
|
'wss://nos.lol/',
|
||||||
'wss://relay.primal.net/',
|
'wss://relay.primal.net/',
|
||||||
'wss://nos.lol/'
|
'wss://offchain.pub/'
|
||||||
]
|
]
|
||||||
|
|
||||||
export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.nos.today/']
|
export const SEARCHABLE_RELAY_URLS = [
|
||||||
|
'wss://search.nos.today/',
|
||||||
|
'wss://relay.ditto.pub/',
|
||||||
|
'wss://relay.nostrcheck.me/',
|
||||||
|
'wss://relay.nostr.band/'
|
||||||
|
]
|
||||||
|
|
||||||
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
|
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
|
||||||
|
|
||||||
@@ -134,8 +139,8 @@ export const YOUTUBE_URL_REGEX =
|
|||||||
export const X_URL_REGEX =
|
export const X_URL_REGEX =
|
||||||
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi
|
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi
|
||||||
|
|
||||||
export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
|
export const SMESH_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
|
||||||
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'
|
export const CODY_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
|
||||||
|
|
||||||
export const NIP_96_SERVICE = [
|
export const NIP_96_SERVICE = [
|
||||||
'https://mockingyou.com',
|
'https://mockingyou.com',
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export * from './useFetchProfile'
|
|||||||
export * from './useFetchRelayInfo'
|
export * from './useFetchRelayInfo'
|
||||||
export * from './useFetchRelayInfos'
|
export * from './useFetchRelayInfos'
|
||||||
export * from './useFetchRelayList'
|
export * from './useFetchRelayList'
|
||||||
|
export * from './useInfiniteScroll'
|
||||||
export * from './useSearchProfiles'
|
export * from './useSearchProfiles'
|
||||||
export * from './useTranslatedEvent'
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -7,7 +6,6 @@ import { useEffect, useState } from 'react'
|
|||||||
export function useFetchEvent(eventId?: string) {
|
export function useFetchEvent(eventId?: string) {
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const [isFetching, setIsFetching] = useState(true)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [event, setEvent] = useState<Event | undefined>(undefined)
|
const [event, setEvent] = useState<Event | undefined>(undefined)
|
||||||
|
|
||||||
@@ -23,7 +21,6 @@ export function useFetchEvent(eventId?: string) {
|
|||||||
const event = await client.fetchEvent(eventId)
|
const event = await client.fetchEvent(eventId)
|
||||||
if (event && !isEventDeleted(event)) {
|
if (event && !isEventDeleted(event)) {
|
||||||
setEvent(event)
|
setEvent(event)
|
||||||
addReplies([event])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
119
src/hooks/useInfiniteScroll.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface UseInfiniteScrollOptions<T> {
|
||||||
|
/**
|
||||||
|
* The initial data items
|
||||||
|
*/
|
||||||
|
items: T[]
|
||||||
|
/**
|
||||||
|
* Whether to initially show all items or use pagination
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
showAllInitially?: boolean
|
||||||
|
/**
|
||||||
|
* Number of items to show initially and load per batch
|
||||||
|
* @default 10
|
||||||
|
*/
|
||||||
|
showCount?: number
|
||||||
|
/**
|
||||||
|
* Initial loading state, which can be used to prevent loading more data until initial load is complete
|
||||||
|
*/
|
||||||
|
initialLoading?: boolean
|
||||||
|
/**
|
||||||
|
* The function to load more data
|
||||||
|
* Returns true if there are more items to load, false otherwise
|
||||||
|
*/
|
||||||
|
onLoadMore: () => Promise<boolean>
|
||||||
|
/**
|
||||||
|
* IntersectionObserver options
|
||||||
|
*/
|
||||||
|
observerOptions?: IntersectionObserverInit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteScroll<T>({
|
||||||
|
items,
|
||||||
|
showAllInitially = false,
|
||||||
|
showCount: initialShowCount = 10,
|
||||||
|
onLoadMore,
|
||||||
|
initialLoading = false,
|
||||||
|
observerOptions = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0
|
||||||
|
}
|
||||||
|
}: UseInfiniteScrollOptions<T>) {
|
||||||
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [showCount, setShowCount] = useState(showAllInitially ? Infinity : initialShowCount)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const stateRef = useRef({
|
||||||
|
loading,
|
||||||
|
hasMore,
|
||||||
|
showCount,
|
||||||
|
itemsLength: items.length,
|
||||||
|
initialLoading
|
||||||
|
})
|
||||||
|
|
||||||
|
stateRef.current = {
|
||||||
|
loading,
|
||||||
|
hasMore,
|
||||||
|
showCount,
|
||||||
|
itemsLength: items.length,
|
||||||
|
initialLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
|
||||||
|
|
||||||
|
if (initialLoading || loading) return
|
||||||
|
|
||||||
|
// If there are more items to show, increase showCount first
|
||||||
|
if (showCount < itemsLength) {
|
||||||
|
setShowCount((prev) => prev + initialShowCount)
|
||||||
|
// Only fetch more data when remaining items are running low
|
||||||
|
if (itemsLength - showCount > initialShowCount * 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) return
|
||||||
|
setLoading(true)
|
||||||
|
const newHasMore = await onLoadMore()
|
||||||
|
setHasMore(newHasMore)
|
||||||
|
setLoading(false)
|
||||||
|
}, [onLoadMore, initialShowCount])
|
||||||
|
|
||||||
|
// IntersectionObserver setup
|
||||||
|
useEffect(() => {
|
||||||
|
const currentBottomRef = bottomRef.current
|
||||||
|
if (!currentBottomRef) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, observerOptions)
|
||||||
|
|
||||||
|
observer.observe(currentBottomRef)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [loadMore, observerOptions])
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
return showAllInitially ? items : items.slice(0, showCount)
|
||||||
|
}, [items, showAllInitially, showCount])
|
||||||
|
|
||||||
|
const shouldShowLoadingIndicator = hasMore || showCount < items.length || loading
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleItems,
|
||||||
|
loading,
|
||||||
|
hasMore,
|
||||||
|
shouldShowLoadingIndicator,
|
||||||
|
bottomRef,
|
||||||
|
setHasMore,
|
||||||
|
setLoading
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/hooks/useThread.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import threadService from '@/services/thread.service'
|
||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
export function useThread(stuffKey: string) {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => threadService.listenThread(stuffKey, cb),
|
||||||
|
() => threadService.getThread(stuffKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllDescendantThreads(stuffKey: string) {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => threadService.listenAllDescendantThreads(stuffKey, cb),
|
||||||
|
() => threadService.getAllDescendantThreads(stuffKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
export function useTranslatedEvent(eventId?: string) {
|
|
||||||
const { translatedEventIdSet, getTranslatedEvent } = useTranslationService()
|
|
||||||
const translated = useMemo(() => {
|
|
||||||
return eventId ? translatedEventIdSet.has(eventId) : false
|
|
||||||
}, [eventId, translatedEventIdSet])
|
|
||||||
const [translatedEvent, setTranslatedEvent] = useState<Event | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (translated && eventId) {
|
|
||||||
setTranslatedEvent(getTranslatedEvent(eventId))
|
|
||||||
} else {
|
|
||||||
setTranslatedEvent(null)
|
|
||||||
}
|
|
||||||
}, [translated, eventId])
|
|
||||||
|
|
||||||
return translatedEvent
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@ import pt_PT from './locales/pt-PT'
|
|||||||
import ru from './locales/ru'
|
import ru from './locales/ru'
|
||||||
import th from './locales/th'
|
import th from './locales/th'
|
||||||
import zh from './locales/zh'
|
import zh from './locales/zh'
|
||||||
|
import zh_TW from './locales/zh-TW'
|
||||||
|
|
||||||
const languages = {
|
const languages = {
|
||||||
ar: { resource: ar, name: '╪з┘Д╪╣╪▒╪и┘К╪й' },
|
ar: { resource: ar, name: '╪з┘Д╪╣╪▒╪и┘К╪й' },
|
||||||
@@ -37,7 +38,8 @@ const languages = {
|
|||||||
'pt-PT': { resource: pt_PT, name: 'Portugu├кs (Portugal)' },
|
'pt-PT': { resource: pt_PT, name: 'Portugu├кs (Portugal)' },
|
||||||
ru: { resource: ru, name: '╨а╤Г╤Б╤Б╨║╨╕╨╣' },
|
ru: { resource: ru, name: '╨а╤Г╤Б╤Б╨║╨╕╨╣' },
|
||||||
th: { resource: th, name: 'р╣Др╕Чр╕в' },
|
th: { resource: th, name: 'р╣Др╕Чр╕в' },
|
||||||
zh: { resource: zh, name: 'чоАф╜Уф╕нцЦЗ' }
|
zh: { resource: zh, name: 'чоАф╜Уф╕нцЦЗ' },
|
||||||
|
'zh-TW': { resource: zh_TW, name: 'ч╣БщлФф╕нцЦЗ' }
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type TLanguage = keyof typeof languages
|
export type TLanguage = keyof typeof languages
|
||||||
@@ -62,6 +64,10 @@ i18n
|
|||||||
},
|
},
|
||||||
detection: {
|
detection: {
|
||||||
convertDetectedLanguage: (lng) => {
|
convertDetectedLanguage: (lng) => {
|
||||||
|
console.log('Detected language:', lng)
|
||||||
|
if (lng.startsWith('zh')) {
|
||||||
|
return ['zh', 'zh-CN', 'zh-SG'].includes(lng) ? 'zh' : 'zh-TW'
|
||||||
|
}
|
||||||
const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
|
const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
|
||||||
return supported || 'en'
|
return supported || 'en'
|
||||||
}
|
}
|
||||||
@@ -71,6 +77,7 @@ i18n
|
|||||||
i18n.services.formatter?.add('date', (timestamp, lng) => {
|
i18n.services.formatter?.add('date', (timestamp, lng) => {
|
||||||
switch (lng) {
|
switch (lng) {
|
||||||
case 'zh':
|
case 'zh':
|
||||||
|
case 'zh-TW':
|
||||||
case 'ja':
|
case 'ja':
|
||||||
return dayjs(timestamp).format('YYYYх╣┤MMцЬИDDцЧе')
|
return dayjs(timestamp).format('YYYYх╣┤MMцЬИDDцЧе')
|
||||||
case 'pl':
|
case 'pl':
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
'Add an Account': '╪е╪╢╪з┘Б╪й ╪н╪│╪з╪и',
|
'Add an Account': '╪е╪╢╪з┘Б╪й ╪н╪│╪з╪и',
|
||||||
'More options': '╪з┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪о┘К╪з╪▒╪з╪к',
|
'More options': '╪з┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪о┘К╪з╪▒╪з╪к',
|
||||||
'Add client tag': '╪е╪╢╪з┘Б╪й ┘И╪│┘Е ╪з┘Д╪╣┘Е┘К┘Д',
|
'Add client tag': '╪е╪╢╪з┘Б╪й ┘И╪│┘Е ╪з┘Д╪╣┘Е┘К┘Д',
|
||||||
'Show others this was sent via Jumble': '╪╣╪▒╪╢ ╪г┘Ж ┘З╪░┘З ╪з┘Д╪▒╪│╪з┘Д╪й ╪г┘П╪▒╪│┘Д╪к ╪╣╪и╪▒ Jumble',
|
'Show others this was sent via Smesh': '╪╣╪▒╪╢ ╪г┘Ж ┘З╪░┘З ╪з┘Д╪▒╪│╪з┘Д╪й ╪г┘П╪▒╪│┘Д╪к ╪╣╪и╪▒ Smesh',
|
||||||
'Are you sure you want to logout?': '┘З┘Д ╪г┘Ж╪к ┘Е╪к╪г┘Г╪п ╪г┘Ж┘Г ╪к╪▒┘К╪п ╪к╪│╪м┘К┘Д ╪з┘Д╪о╪▒┘И╪м╪Я',
|
'Are you sure you want to logout?': '┘З┘Д ╪г┘Ж╪к ┘Е╪к╪г┘Г╪п ╪г┘Ж┘Г ╪к╪▒┘К╪п ╪к╪│╪м┘К┘Д ╪з┘Д╪о╪▒┘И╪м╪Я',
|
||||||
'relay sets': '┘Е╪м┘Е┘И╪╣╪з╪к ╪з┘Д╪▒┘К┘Д╪з┘К',
|
'relay sets': '┘Е╪м┘Е┘И╪╣╪з╪к ╪з┘Д╪▒┘К┘Д╪з┘К',
|
||||||
edit: '╪к╪╣╪п┘К┘Д',
|
edit: '╪к╪╣╪п┘К┘Д',
|
||||||
@@ -195,9 +195,9 @@ export default {
|
|||||||
All: '╪з┘Д┘Г┘Д',
|
All: '╪з┘Д┘Г┘Д',
|
||||||
Reactions: '╪з┘Д╪к┘Б╪з╪╣┘Д╪з╪к',
|
Reactions: '╪з┘Д╪к┘Б╪з╪╣┘Д╪з╪к',
|
||||||
Zaps: 'Zaps',
|
Zaps: 'Zaps',
|
||||||
'Enjoying Jumble?': '┘З┘Д ╪к╪│╪к┘Е╪к╪╣ ╪и┘А Jumble╪Я',
|
'Enjoying Smesh?': '┘З┘Д ╪к╪│╪к┘Е╪к╪╣ ╪и┘А Smesh╪Я',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'╪к╪и╪▒╪╣┘Г ┘К╪│╪з╪╣╪п ┘Б┘К ╪╡┘К╪з┘Ж╪й Jumble ┘И╪к╪н╪│┘К┘Ж┘З! ЁЯШК',
|
'╪к╪и╪▒╪╣┘Г ┘К╪│╪з╪╣╪п ┘Б┘К ╪╡┘К╪з┘Ж╪й Smesh ┘И╪к╪н╪│┘К┘Ж┘З! ЁЯШК',
|
||||||
'Earlier notifications': '╪з┘Д╪е╪┤╪╣╪з╪▒╪з╪к ╪з┘Д╪│╪з╪и┘В╪й',
|
'Earlier notifications': '╪з┘Д╪е╪┤╪╣╪з╪▒╪з╪к ╪з┘Д╪│╪з╪и┘В╪й',
|
||||||
'Temporarily display this note': '╪╣╪▒╪╢ ┘З╪░┘З ╪з┘Д┘Е┘Д╪з╪н╪╕╪й ┘Е╪д┘В╪к╪з┘Л',
|
'Temporarily display this note': '╪╣╪▒╪╢ ┘З╪░┘З ╪з┘Д┘Е┘Д╪з╪н╪╕╪й ┘Е╪д┘В╪к╪з┘Л',
|
||||||
buttonFollowing: '╪м╪з╪▒┘Н ╪з┘Д┘Е╪к╪з╪и╪╣╪й',
|
buttonFollowing: '╪м╪з╪▒┘Н ╪з┘Д┘Е╪к╪з╪и╪╣╪й',
|
||||||
@@ -250,7 +250,7 @@ export default {
|
|||||||
Translation: '╪з┘Д╪к╪▒╪м┘Е╪й',
|
Translation: '╪з┘Д╪к╪▒╪м┘Е╪й',
|
||||||
Balance: '╪з┘Д╪▒╪╡┘К╪п',
|
Balance: '╪з┘Д╪▒╪╡┘К╪п',
|
||||||
characters: '╪з┘Д╪н╪▒┘И┘Б',
|
characters: '╪з┘Д╪н╪▒┘И┘Б',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'┘К┘Е┘Г┘Ж┘Г ╪з╪│╪к╪о╪п╪з┘Е ┘Е┘Б╪к╪з╪н API ┘З╪░╪з ┘Б┘К ╪г┘К ┘Е┘Г╪з┘Ж ╪в╪о╪▒ ┘К╪п╪╣┘Е LibreTranslate. ╪╣┘Ж┘И╪з┘Ж ╪з┘Д╪о╪п┘Е╪й ┘З┘И {{serviceUrl}}',
|
'┘К┘Е┘Г┘Ж┘Г ╪з╪│╪к╪о╪п╪з┘Е ┘Е┘Б╪к╪з╪н API ┘З╪░╪з ┘Б┘К ╪г┘К ┘Е┘Г╪з┘Ж ╪в╪о╪▒ ┘К╪п╪╣┘Е LibreTranslate. ╪╣┘Ж┘И╪з┘Ж ╪з┘Д╪о╪п┘Е╪й ┘З┘И {{serviceUrl}}',
|
||||||
'Top up': '╪е╪╣╪з╪п╪й ╪┤╪н┘Ж',
|
'Top up': '╪е╪╣╪з╪п╪й ╪┤╪н┘Ж',
|
||||||
'Will receive: {n} characters': '╪│╪к╪к┘Д┘В┘Й: {{n}} ╪н╪▒┘И┘Б',
|
'Will receive: {n} characters': '╪│╪к╪к┘Д┘В┘Й: {{n}} ╪н╪▒┘И┘Б',
|
||||||
@@ -384,6 +384,7 @@ export default {
|
|||||||
'reacted to your note': '╪к┘Б╪з╪╣┘Д ┘Е╪╣ ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
'reacted to your note': '╪к┘Б╪з╪╣┘Д ┘Е╪╣ ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
||||||
'reposted your note': '╪г╪╣╪з╪п ┘Ж╪┤╪▒ ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
'reposted your note': '╪г╪╣╪з╪п ┘Ж╪┤╪▒ ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
||||||
'zapped your note': '╪▓╪з╪и ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
'zapped your note': '╪▓╪з╪и ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
||||||
|
'highlighted your note': '╪г╪и╪▒╪▓ ┘Е┘Д╪з╪н╪╕╪к┘Г',
|
||||||
'zapped you': '╪▓╪з╪и┘Г',
|
'zapped you': '╪▓╪з╪и┘Г',
|
||||||
'Mark as read': '╪к╪╣┘Д┘К┘Е ┘Г┘Е┘В╪▒┘И╪б',
|
'Mark as read': '╪к╪╣┘Д┘К┘Е ┘Г┘Е┘В╪▒┘И╪б',
|
||||||
Report: '╪к╪и┘Д┘К╪║',
|
Report: '╪к╪и┘Д┘К╪║',
|
||||||
@@ -488,14 +489,14 @@ export default {
|
|||||||
Remote: '╪╣┘Ж ╪и┘П╪╣╪п',
|
Remote: '╪╣┘Ж ╪и┘П╪╣╪п',
|
||||||
'Encrypted Key': '┘Е┘Б╪к╪з╪н ┘Е╪┤┘Б╪▒',
|
'Encrypted Key': '┘Е┘Б╪к╪з╪н ┘Е╪┤┘Б╪▒',
|
||||||
'Private Key': '┘Е┘Б╪к╪з╪н ╪о╪з╪╡',
|
'Private Key': '┘Е┘Б╪к╪з╪н ╪о╪з╪╡',
|
||||||
'Welcome to Jumble': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Jumble',
|
'Welcome to Smesh': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Smesh',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble ┘З┘И ╪╣┘Е┘К┘Д ┘К╪▒┘Г╪▓ ╪╣┘Д┘Й ╪к╪╡┘Б╪н ╪з┘Д┘Е╪▒╪н┘Д╪з╪к. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к ╪з┘Д┘Е╪л┘К╪▒╪й ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ╪г┘И ┘В┘Е ╪и╪к╪│╪м┘К┘Д ╪з┘Д╪п╪о┘И┘Д ┘Д╪╣╪▒╪╢ ╪о┘Д╪з╪╡╪к┘Г.',
|
'Smesh ┘З┘И ╪╣┘Е┘К┘Д ┘К╪▒┘Г╪▓ ╪╣┘Д┘Й ╪к╪╡┘Б╪н ╪з┘Д┘Е╪▒╪н┘Д╪з╪к. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к ╪з┘Д┘Е╪л┘К╪▒╪й ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ╪г┘И ┘В┘Е ╪и╪к╪│╪м┘К┘Д ╪з┘Д╪п╪о┘И┘Д ┘Д╪╣╪▒╪╢ ╪о┘Д╪з╪╡╪к┘Г.',
|
||||||
'Explore Relays': '╪з╪│╪к┘Г╪┤┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к',
|
'Explore Relays': '╪з╪│╪к┘Г╪┤┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к',
|
||||||
'Choose a feed': '╪з╪о╪к╪▒ ╪о┘Д╪з╪╡╪й',
|
'Choose a feed': '╪з╪о╪к╪▒ ╪о┘Д╪з╪╡╪й',
|
||||||
'and {{x}} others': '┘И {{x}} ╪в╪о╪▒┘И┘Ж',
|
'and {{x}} others': '┘И {{x}} ╪в╪о╪▒┘И┘Ж',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble ╪║┘К╪▒ ┘Е╪│╪д┘И┘Д╪й ╪╣┘Е╪з ┘К╪н╪п╪л ╪е╪░╪з ╪г╪▒╪│┘Д╪к zap ┘Д┘Ж┘Б╪│┘Г. ╪к╪з╪и╪╣ ╪╣┘Д┘Й ┘Е╪│╪д┘И┘Д┘К╪к┘Г ╪з┘Д╪о╪з╪╡╪й. ЁЯШЙтЪб',
|
'Smesh ╪║┘К╪▒ ┘Е╪│╪д┘И┘Д╪й ╪╣┘Е╪з ┘К╪н╪п╪л ╪е╪░╪з ╪г╪▒╪│┘Д╪к zap ┘Д┘Ж┘Б╪│┘Г. ╪к╪з╪и╪╣ ╪╣┘Д┘Й ┘Е╪│╪д┘И┘Д┘К╪к┘Г ╪з┘Д╪о╪з╪╡╪й. ЁЯШЙтЪб',
|
||||||
'Emoji Pack': '╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
'Emoji Pack': '╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
||||||
'Emoji pack added': '╪к┘Е╪к ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
'Emoji pack added': '╪к┘Е╪к ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
||||||
'Add emoji pack failed': '┘Б╪┤┘Д ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
'Add emoji pack failed': '┘Б╪┤┘Д ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
|
||||||
@@ -583,6 +584,56 @@ export default {
|
|||||||
'Special Follow': '┘Е╪к╪з╪и╪╣╪й ╪о╪з╪╡╪й',
|
'Special Follow': '┘Е╪к╪з╪и╪╣╪й ╪о╪з╪╡╪й',
|
||||||
'Unfollow Special': '╪е┘Д╪║╪з╪б ╪з┘Д┘Е╪к╪з╪и╪╣╪й ╪з┘Д╪о╪з╪╡╪й',
|
'Unfollow Special': '╪е┘Д╪║╪з╪б ╪з┘Д┘Е╪к╪з╪и╪╣╪й ╪з┘Д╪о╪з╪╡╪й',
|
||||||
'Personal Feeds': '╪з┘Д╪к╪п┘Б┘В╪з╪к ╪з┘Д╪┤╪о╪╡┘К╪й',
|
'Personal Feeds': '╪з┘Д╪к╪п┘Б┘В╪з╪к ╪з┘Д╪┤╪о╪╡┘К╪й',
|
||||||
'Relay Feeds': '╪к╪п┘Б┘В╪з╪к ╪з┘Д╪к╪▒╪н┘К┘Д'
|
'Relay Feeds': '╪к╪п┘Б┘В╪з╪к ╪з┘Д╪к╪▒╪н┘К┘Д',
|
||||||
|
'Create Highlight': '╪е┘Ж╪┤╪з╪б ╪к┘Е┘К┘К╪▓',
|
||||||
|
'Write your thoughts about this highlight...': '╪з┘Г╪к╪и ╪г┘Б┘Г╪з╪▒┘Г ╪н┘И┘Д ┘З╪░╪з ╪з┘Д╪к┘Е┘К┘К╪▓...',
|
||||||
|
'Publish Highlight': '┘Ж╪┤╪▒ ╪з┘Д╪к┘Е┘К┘К╪▓',
|
||||||
|
'Show replies': '╪е╪╕┘З╪з╪▒ ╪з┘Д╪▒╪п┘И╪п',
|
||||||
|
'Hide replies': '╪е╪о┘Б╪з╪б ╪з┘Д╪▒╪п┘И╪п',
|
||||||
|
'Welcome to Smesh!': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Smesh!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'╪о┘Д╪з╪╡╪к┘Г ┘Б╪з╪▒╪║╪й ┘Д╪г┘Ж┘Г ┘Д╪з ╪к╪к╪з╪и╪╣ ╪г┘К ╪┤╪о╪╡ ╪и╪╣╪п. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ┘Е╪н╪к┘И┘Й ┘Е╪л┘К╪▒ ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ┘И┘Е╪к╪з╪и╪╣╪й ╪з┘Д┘Е╪│╪к╪о╪п┘Е┘К┘Ж ╪з┘Д╪░┘К┘Ж ╪к╪н╪и┘З┘Е!',
|
||||||
|
'Search Users': '╪з┘Д╪и╪н╪л ╪╣┘Ж ╪з┘Д┘Е╪│╪к╪о╪п┘Е┘К┘Ж',
|
||||||
|
'Create New Account': '╪е┘Ж╪┤╪з╪б ╪н╪│╪з╪и ╪м╪п┘К╪п',
|
||||||
|
Important: '┘Е┘З┘Е',
|
||||||
|
'Generate Your Account': '╪е┘Ж╪┤╪з╪б ╪н╪│╪з╪и┘Г',
|
||||||
|
'Your private key IS your account. Keep it safe!': '┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ╪з╪н╪к┘Б╪╕ ╪и┘З ╪и╪г┘Е╪з┘Ж!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'┘Б┘К Nostr╪М ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ╪е╪░╪з ┘Б┘В╪п╪к ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡╪М ╪│╪к┘Б┘В╪п ╪н╪│╪з╪и┘Г ╪е┘Д┘Й ╪з┘Д╪г╪и╪п.',
|
||||||
|
'Your Private Key': '┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡',
|
||||||
|
'Generate new key': '╪е┘Ж╪┤╪з╪б ┘Е┘Б╪к╪з╪н ╪м╪п┘К╪п',
|
||||||
|
'Download Backup File': '╪к┘Ж╪▓┘К┘Д ┘Е┘Д┘Б ╪з┘Д┘Ж╪│╪о ╪з┘Д╪з╪н╪к┘К╪з╪╖┘К',
|
||||||
|
'Copied to Clipboard': '╪к┘Е ╪з┘Д┘Ж╪│╪о ╪е┘Д┘Й ╪з┘Д╪н╪з┘Б╪╕╪й',
|
||||||
|
'Copy to Clipboard': '┘Ж╪│╪о ╪е┘Д┘Й ╪з┘Д╪н╪з┘Б╪╕╪й',
|
||||||
|
'I already saved my private key securely.': '┘Д┘В╪п ╪н┘Б╪╕╪к ┘Е┘Б╪к╪з╪н┘К ╪з┘Д╪о╪з╪╡ ╪и╪┤┘Г┘Д ╪в┘Е┘Ж ╪и╪з┘Д┘Б╪╣┘Д.',
|
||||||
|
'Almost Done!': '╪╣┘Д┘Й ┘И╪┤┘Г ╪з┘Д╪з┘Ж╪к┘З╪з╪б!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'┘В┘Е ╪и╪к╪╣┘К┘К┘Ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ┘Д╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г╪М ╪г┘И ╪к╪о╪╖┘Й ┘Д┘Д╪з┘Ж╪к┘З╪з╪б',
|
||||||
|
'Password Protection (Optional)': '╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (╪з╪о╪к┘К╪з╪▒┘К)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'┘К╪д╪п┘К ╪к╪╣┘К┘К┘Ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ╪е┘Д┘Й ╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘Б┘К ┘З╪░╪з ╪з┘Д┘Е╪к╪╡┘Б╪н. ┘К┘Е┘Г┘Ж┘Г ╪к╪о╪╖┘К ┘З╪░┘З ╪з┘Д╪о╪╖┘И╪й╪М ┘Д┘Г┘Ж┘Ж╪з ┘Ж┘И╪╡┘К ╪и╪к╪╣┘К┘К┘Ж ┘И╪з╪н╪п╪й ┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪г┘Е╪з┘Ж.',
|
||||||
|
'Password (Optional)': '┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ (╪з╪о╪к┘К╪з╪▒┘К)',
|
||||||
|
'Enter password or leave empty to skip': '╪г╪п╪о┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ ╪г┘И ╪з╪к╪▒┘Г┘З╪з ┘Б╪з╪▒╪║╪й ┘Д┘Д╪к╪о╪╖┘К',
|
||||||
|
'Confirm Password': '╪к╪г┘Г┘К╪п ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒',
|
||||||
|
'Re-enter password': '╪г╪╣╪п ╪е╪п╪о╪з┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒',
|
||||||
|
'Passwords do not match': '┘Г┘Д┘Е╪з╪к ╪з┘Д┘Е╪▒┘И╪▒ ╪║┘К╪▒ ┘Е╪к╪╖╪з╪и┘В╪й',
|
||||||
|
'Finish Signup': '╪е┘Ж┘З╪з╪б ╪з┘Д╪к╪│╪м┘К┘Д',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': '╪г┘Ж╪┤╪ж ╪н╪│╪з╪и Nostr ╪з┘Д╪о╪з╪╡ ╪и┘Г',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'╪г┘Ж╪┤╪ж ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ╪з┘Д┘Б╪▒┘К╪п. ┘З╪░┘З ┘З┘К ┘З┘И┘К╪к┘Г ╪з┘Д╪▒┘В┘Е┘К╪й.',
|
||||||
|
'Critical: Save Your Private Key': '╪н╪▒╪м: ╪з╪н┘Б╪╕ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ┘Д╪з ┘К┘И╪м╪п ╪з╪│╪к╪▒╪п╪з╪п ┘Д┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒. ╪е╪░╪з ┘Б┘В╪п╪к┘З╪М ╪│╪к┘Б┘В╪п ╪н╪│╪з╪и┘Г ┘Д┘Д╪г╪и╪п. ┘К╪▒╪м┘Й ╪н┘Б╪╕┘З ┘Б┘К ┘Е┘Г╪з┘Ж ╪в┘Е┘Ж.',
|
||||||
|
'I have safely backed up my private key': '┘Д┘В╪п ┘В┘Е╪к ╪и╪╣┘Е┘Д ┘Ж╪│╪о╪й ╪з╪н╪к┘К╪з╪╖┘К╪й ╪в┘Е┘Ж╪й ┘Д┘Е┘Б╪к╪з╪н┘К ╪з┘Д╪о╪з╪╡',
|
||||||
|
'Secure Your Account': '╪г┘Е┘С┘Ж ╪н╪│╪з╪и┘Г',
|
||||||
|
'Add an extra layer of protection with a password': '╪г╪╢┘Б ╪╖╪и┘В╪й ╪е╪╢╪з┘Б┘К╪й ┘Е┘Ж ╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒',
|
||||||
|
'Password Protection (Recommended)': '╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (┘Е┘И╪╡┘Й ╪и┘З)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'╪г╪╢┘Б ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ┘Д╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘Б┘К ┘З╪░╪з ╪з┘Д┘Е╪к╪╡┘Б╪н. ┘З╪░╪з ╪з╪о╪к┘К╪з╪▒┘К ┘Д┘Г┘Ж┘З ┘Е┘И╪╡┘Й ╪и┘З ╪и╪┤╪п╪й ┘Д╪г┘Е╪з┘Ж ╪г┘Б╪╢┘Д.',
|
||||||
|
'Create a password (or skip)': '╪г┘Ж╪┤╪ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (╪г┘И ╪к╪о╪╖┘Й)',
|
||||||
|
'Enter your password again': '╪г╪п╪о┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ ┘Е╪▒╪й ╪г╪о╪▒┘Й',
|
||||||
|
'Complete Signup': '╪е┘Г┘Е╪з┘Д ╪з┘Д╪к╪│╪м┘К┘Д',
|
||||||
|
Recommended: '┘Е┘И╪╡┘Й ╪и┘З'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
'Add an Account': 'Konto hinzuf├╝gen',
|
'Add an Account': 'Konto hinzuf├╝gen',
|
||||||
'More options': 'Mehr Optionen',
|
'More options': 'Mehr Optionen',
|
||||||
'Add client tag': 'Client-Tag hinzuf├╝gen',
|
'Add client tag': 'Client-Tag hinzuf├╝gen',
|
||||||
'Show others this was sent via Jumble': 'Anderen zeigen, dass dies ├╝ber Jumble gesendet wurde',
|
'Show others this was sent via Smesh': 'Anderen zeigen, dass dies ├╝ber Smesh gesendet wurde',
|
||||||
'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden m├╢chtest?',
|
'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden m├╢chtest?',
|
||||||
'relay sets': 'Relay-Sets',
|
'relay sets': 'Relay-Sets',
|
||||||
edit: 'bearbeiten',
|
edit: 'bearbeiten',
|
||||||
@@ -199,9 +199,9 @@ export default {
|
|||||||
All: 'Alle',
|
All: 'Alle',
|
||||||
Reactions: 'Reaktionen',
|
Reactions: 'Reaktionen',
|
||||||
Zaps: 'Zaps',
|
Zaps: 'Zaps',
|
||||||
'Enjoying Jumble?': 'Gef├дllt dir Jumble?',
|
'Enjoying Smesh?': 'Gef├дllt dir Smesh?',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! ЁЯШК',
|
'Deine Spende hilft mir, Smesh zu pflegen und zu verbessern! ЁЯШК',
|
||||||
'Earlier notifications': 'Fr├╝here Benachrichtigungen',
|
'Earlier notifications': 'Fr├╝here Benachrichtigungen',
|
||||||
'Temporarily display this note': 'Notiz vor├╝bergehend anzeigen',
|
'Temporarily display this note': 'Notiz vor├╝bergehend anzeigen',
|
||||||
buttonFollowing: 'Folge',
|
buttonFollowing: 'Folge',
|
||||||
@@ -257,7 +257,7 @@ export default {
|
|||||||
Translation: '├Ьbersetzung',
|
Translation: '├Ьbersetzung',
|
||||||
Balance: 'Guthaben',
|
Balance: 'Guthaben',
|
||||||
characters: 'Zeichen',
|
characters: 'Zeichen',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'Du kannst diesen API-Schl├╝ssel ├╝berall dort verwenden, wo LibreTranslate unterst├╝tzt wird. Die Service-URL ist {{serviceUrl}}',
|
'Du kannst diesen API-Schl├╝ssel ├╝berall dort verwenden, wo LibreTranslate unterst├╝tzt wird. Die Service-URL ist {{serviceUrl}}',
|
||||||
'Top up': 'Aufladen',
|
'Top up': 'Aufladen',
|
||||||
'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen',
|
'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen',
|
||||||
@@ -393,6 +393,7 @@ export default {
|
|||||||
'reacted to your note': 'hat auf Ihre Notiz reagiert',
|
'reacted to your note': 'hat auf Ihre Notiz reagiert',
|
||||||
'reposted your note': 'hat Ihre Notiz geteilt',
|
'reposted your note': 'hat Ihre Notiz geteilt',
|
||||||
'zapped your note': 'hat Ihre Notiz gezappt',
|
'zapped your note': 'hat Ihre Notiz gezappt',
|
||||||
|
'highlighted your note': 'hat Ihre Notiz hervorgehoben',
|
||||||
'zapped you': 'hat Sie gezappt',
|
'zapped you': 'hat Sie gezappt',
|
||||||
'Mark as read': 'Als gelesen markieren',
|
'Mark as read': 'Als gelesen markieren',
|
||||||
Report: 'Melden',
|
Report: 'Melden',
|
||||||
@@ -502,14 +503,14 @@ export default {
|
|||||||
Remote: 'Remote',
|
Remote: 'Remote',
|
||||||
'Encrypted Key': 'Verschl├╝sselter Schl├╝ssel',
|
'Encrypted Key': 'Verschl├╝sselter Schl├╝ssel',
|
||||||
'Private Key': 'Privater Schl├╝ssel',
|
'Private Key': 'Privater Schl├╝ssel',
|
||||||
'Welcome to Jumble': 'Willkommen bei Jumble',
|
'Welcome to Smesh': 'Willkommen bei Smesh',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble ist ein Client, der sich auf das Durchsuchen von Relays konzentriert. Beginnen Sie mit der Erkundung interessanter Relays oder melden Sie sich an, um Ihren Following-Feed anzuzeigen.',
|
'Smesh ist ein Client, der sich auf das Durchsuchen von Relays konzentriert. Beginnen Sie mit der Erkundung interessanter Relays oder melden Sie sich an, um Ihren Following-Feed anzuzeigen.',
|
||||||
'Explore Relays': 'Relays erkunden',
|
'Explore Relays': 'Relays erkunden',
|
||||||
'Choose a feed': 'W├дhle einen Feed',
|
'Choose a feed': 'W├дhle einen Feed',
|
||||||
'and {{x}} others': 'und {{x}} andere',
|
'and {{x}} others': 'und {{x}} andere',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble ist nicht verantwortlich f├╝r das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. ЁЯШЙтЪб',
|
'Smesh ist nicht verantwortlich f├╝r das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. ЁЯШЙтЪб',
|
||||||
'Emoji Pack': 'Emoji-Paket',
|
'Emoji Pack': 'Emoji-Paket',
|
||||||
'Emoji pack added': 'Emoji-Paket hinzugef├╝gt',
|
'Emoji pack added': 'Emoji-Paket hinzugef├╝gt',
|
||||||
'Add emoji pack failed': 'Hinzuf├╝gen des Emoji-Pakets fehlgeschlagen',
|
'Add emoji pack failed': 'Hinzuf├╝gen des Emoji-Pakets fehlgeschlagen',
|
||||||
@@ -599,6 +600,61 @@ export default {
|
|||||||
'Special Follow': 'Besonders Folgen',
|
'Special Follow': 'Besonders Folgen',
|
||||||
'Unfollow Special': 'Besonders Entfolgen',
|
'Unfollow Special': 'Besonders Entfolgen',
|
||||||
'Personal Feeds': 'Pers├╢nliche Feeds',
|
'Personal Feeds': 'Pers├╢nliche Feeds',
|
||||||
'Relay Feeds': 'Relay-Feeds'
|
'Relay Feeds': 'Relay-Feeds',
|
||||||
|
'Create Highlight': 'Markierung Erstellen',
|
||||||
|
'Write your thoughts about this highlight...':
|
||||||
|
'Schreiben Sie Ihre Gedanken zu dieser Markierung...',
|
||||||
|
'Publish Highlight': 'Markierung Ver├╢ffentlichen',
|
||||||
|
'Show replies': 'Antworten anzeigen',
|
||||||
|
'Hide replies': 'Antworten ausblenden',
|
||||||
|
'Welcome to Smesh!': 'Willkommen bei Smesh!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'Ihr Feed ist leer, weil Sie noch niemandem folgen. Beginnen Sie damit, interessante Inhalte zu erkunden und Benutzern zu folgen, die Ihnen gefallen!',
|
||||||
|
'Search Users': 'Benutzer suchen',
|
||||||
|
'Create New Account': 'Neues Konto erstellen',
|
||||||
|
Important: 'Wichtig',
|
||||||
|
'Generate Your Account': 'Konto generieren',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'Ihr privater Schl├╝ssel IST Ihr Konto. Bewahren Sie ihn sicher auf!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'In Nostr IST Ihr privater Schl├╝ssel Ihr Konto. Wenn Sie Ihren privaten Schl├╝ssel verlieren, verlieren Sie Ihr Konto f├╝r immer.',
|
||||||
|
'Your Private Key': 'Ihr privater Schl├╝ssel',
|
||||||
|
'Generate new key': 'Neuen Schl├╝ssel generieren',
|
||||||
|
'Download Backup File': 'Sicherungsdatei herunterladen',
|
||||||
|
'Copied to Clipboard': 'In Zwischenablage kopiert',
|
||||||
|
'Copy to Clipboard': 'In Zwischenablage kopieren',
|
||||||
|
'I already saved my private key securely.':
|
||||||
|
'Ich habe meinen privaten Schl├╝ssel bereits sicher gespeichert.',
|
||||||
|
'Almost Done!': 'Fast fertig!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'Legen Sie ein Passwort fest, um Ihren Schl├╝ssel zu verschl├╝sseln, oder ├╝berspringen Sie, um fertig zu werden',
|
||||||
|
'Password Protection (Optional)': 'Passwortschutz (optional)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'Das Festlegen eines Passworts verschl├╝sselt Ihren privaten Schl├╝ssel in diesem Browser. Sie k├╢nnen diesen Schritt ├╝berspringen, aber wir empfehlen, eines f├╝r zus├дtzliche Sicherheit festzulegen.',
|
||||||
|
'Password (Optional)': 'Passwort (optional)',
|
||||||
|
'Enter password or leave empty to skip':
|
||||||
|
'Passwort eingeben oder leer lassen, um zu ├╝berspringen',
|
||||||
|
'Confirm Password': 'Passwort best├дtigen',
|
||||||
|
'Re-enter password': 'Passwort erneut eingeben',
|
||||||
|
'Passwords do not match': 'Passw├╢rter stimmen nicht ├╝berein',
|
||||||
|
'Finish Signup': 'Registrierung abschlie├Яen',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': 'Erstellen Sie Ihr Nostr-Konto',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'Generieren Sie Ihren einzigartigen privaten Schl├╝ssel. Dies ist Ihre digitale Identit├дt.',
|
||||||
|
'Critical: Save Your Private Key': 'Kritisch: Speichern Sie Ihren privaten Schl├╝ssel',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'Ihr privater Schl├╝ssel IST Ihr Konto. Es gibt keine Passwortwiederherstellung. Wenn Sie ihn verlieren, verlieren Sie Ihr Konto f├╝r immer. Bitte speichern Sie ihn an einem sicheren Ort.',
|
||||||
|
'I have safely backed up my private key': 'Ich habe meinen privaten Schl├╝ssel sicher gesichert',
|
||||||
|
'Secure Your Account': 'Sichern Sie Ihr Konto',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'F├╝gen Sie eine zus├дtzliche Schutzebene mit einem Passwort hinzu',
|
||||||
|
'Password Protection (Recommended)': 'Passwortschutz (empfohlen)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'F├╝gen Sie ein Passwort hinzu, um Ihren privaten Schl├╝ssel in diesem Browser zu verschl├╝sseln. Dies ist optional, aber f├╝r bessere Sicherheit dringend empfohlen.',
|
||||||
|
'Create a password (or skip)': 'Erstellen Sie ein Passwort (oder ├╝berspringen)',
|
||||||
|
'Enter your password again': 'Geben Sie Ihr Passwort erneut ein',
|
||||||
|
'Complete Signup': 'Registrierung abschlie├Яen',
|
||||||
|
Recommended: 'Empfohlen'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
'Add an Account': 'Add an Account',
|
'Add an Account': 'Add an Account',
|
||||||
'More options': 'More options',
|
'More options': 'More options',
|
||||||
'Add client tag': 'Add client tag',
|
'Add client tag': 'Add client tag',
|
||||||
'Show others this was sent via Jumble': 'Show others this was sent via Jumble',
|
'Show others this was sent via Smesh': 'Show others this was sent via Smesh',
|
||||||
'Are you sure you want to logout?': 'Are you sure you want to logout?',
|
'Are you sure you want to logout?': 'Are you sure you want to logout?',
|
||||||
'relay sets': 'relay sets',
|
'relay sets': 'relay sets',
|
||||||
edit: 'edit',
|
edit: 'edit',
|
||||||
@@ -196,9 +196,9 @@ export default {
|
|||||||
All: 'All',
|
All: 'All',
|
||||||
Reactions: 'Reactions',
|
Reactions: 'Reactions',
|
||||||
Zaps: 'Zaps',
|
Zaps: 'Zaps',
|
||||||
'Enjoying Jumble?': 'Enjoying Jumble?',
|
'Enjoying Smesh?': 'Enjoying Smesh?',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК',
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК',
|
||||||
'Earlier notifications': 'Earlier notifications',
|
'Earlier notifications': 'Earlier notifications',
|
||||||
'Temporarily display this note': 'Temporarily display this note',
|
'Temporarily display this note': 'Temporarily display this note',
|
||||||
buttonFollowing: 'Following',
|
buttonFollowing: 'Following',
|
||||||
@@ -233,7 +233,6 @@ export default {
|
|||||||
Preview: 'Preview',
|
Preview: 'Preview',
|
||||||
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':
|
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':
|
||||||
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?',
|
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?',
|
||||||
'Platinum Sponsors': 'Platinum Sponsors',
|
|
||||||
From: 'From',
|
From: 'From',
|
||||||
'Comment on': 'Comment on',
|
'Comment on': 'Comment on',
|
||||||
'View on njump.me': 'View on njump.me',
|
'View on njump.me': 'View on njump.me',
|
||||||
@@ -247,23 +246,6 @@ export default {
|
|||||||
'Lightning Invoice': 'Lightning Invoice',
|
'Lightning Invoice': 'Lightning Invoice',
|
||||||
'Bookmark failed': 'Bookmark failed',
|
'Bookmark failed': 'Bookmark failed',
|
||||||
'Remove bookmark failed': 'Remove bookmark failed',
|
'Remove bookmark failed': 'Remove bookmark failed',
|
||||||
Translation: 'Translation',
|
|
||||||
Balance: 'Balance',
|
|
||||||
characters: 'characters',
|
|
||||||
jumbleTranslateApiKeyDescription:
|
|
||||||
'You can use this API key anywhere else that supports LibreTranslate. The service URL is {{serviceUrl}}',
|
|
||||||
'Top up': 'Top up',
|
|
||||||
'Will receive: {n} characters': 'Will receive: {{n}} characters',
|
|
||||||
'Top up {n} sats': 'Top up {{n}} sats',
|
|
||||||
'Minimum top up is {n} sats': 'Minimum top up is {{n}} sats',
|
|
||||||
Service: 'Service',
|
|
||||||
'Reset API key': 'Reset API key',
|
|
||||||
'Are you sure you want to reset your API key? This action cannot be undone.':
|
|
||||||
'Are you sure you want to reset your API key? This action cannot be undone.',
|
|
||||||
Warning: 'Warning',
|
|
||||||
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
|
|
||||||
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.',
|
|
||||||
'Service address': 'Service address',
|
|
||||||
Pay: 'Pay',
|
Pay: 'Pay',
|
||||||
interactions: 'interactions',
|
interactions: 'interactions',
|
||||||
notifications: 'notifications',
|
notifications: 'notifications',
|
||||||
@@ -280,11 +262,10 @@ export default {
|
|||||||
Continue: 'Continue',
|
Continue: 'Continue',
|
||||||
'Successfully updated mute list': 'Successfully updated mute list',
|
'Successfully updated mute list': 'Successfully updated mute list',
|
||||||
'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
|
'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
|
||||||
'Translating...': 'Translating...',
|
|
||||||
Translate: 'Translate',
|
|
||||||
'Show original': 'Show original',
|
|
||||||
Website: 'Website',
|
Website: 'Website',
|
||||||
'Hide untrusted notes': 'Hide untrusted notes',
|
'Hide untrusted notes': 'Hide untrusted notes',
|
||||||
|
'Hide untrusted interactions': 'Hide untrusted interactions',
|
||||||
|
'Hide untrusted notifications': 'Hide untrusted notifications',
|
||||||
'Open in another client': 'Open in another client',
|
'Open in another client': 'Open in another client',
|
||||||
Community: 'Community',
|
Community: 'Community',
|
||||||
Group: 'Group',
|
Group: 'Group',
|
||||||
@@ -383,6 +364,7 @@ export default {
|
|||||||
'reacted to your note': 'reacted to your note',
|
'reacted to your note': 'reacted to your note',
|
||||||
'reposted your note': 'reposted your note',
|
'reposted your note': 'reposted your note',
|
||||||
'zapped your note': 'zapped your note',
|
'zapped your note': 'zapped your note',
|
||||||
|
'highlighted your note': 'highlighted your note',
|
||||||
'zapped you': 'zapped you',
|
'zapped you': 'zapped you',
|
||||||
'Mark as read': 'Mark as read',
|
'Mark as read': 'Mark as read',
|
||||||
Report: 'Report',
|
Report: 'Report',
|
||||||
@@ -446,6 +428,7 @@ export default {
|
|||||||
'Connect to your Rizful Vault': 'Connect to your Rizful Vault',
|
'Connect to your Rizful Vault': 'Connect to your Rizful Vault',
|
||||||
'Paste your one-time code here': 'Paste your one-time code here',
|
'Paste your one-time code here': 'Paste your one-time code here',
|
||||||
Connect: 'Connect',
|
Connect: 'Connect',
|
||||||
|
'Connect Wallet': 'Connect Wallet',
|
||||||
'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!',
|
'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!',
|
||||||
'Set up': 'Set up',
|
'Set up': 'Set up',
|
||||||
Pinned: 'Pinned',
|
Pinned: 'Pinned',
|
||||||
@@ -488,14 +471,14 @@ export default {
|
|||||||
Remote: 'Remote',
|
Remote: 'Remote',
|
||||||
'Encrypted Key': 'Encrypted Key',
|
'Encrypted Key': 'Encrypted Key',
|
||||||
'Private Key': 'Private Key',
|
'Private Key': 'Private Key',
|
||||||
'Welcome to Jumble': 'Welcome to Jumble',
|
'Welcome to Smesh': 'Welcome to Smesh',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.',
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.',
|
||||||
'Explore Relays': 'Explore Relays',
|
'Explore Relays': 'Explore Relays',
|
||||||
'Choose a feed': 'Choose a feed',
|
'Choose a feed': 'Choose a feed',
|
||||||
'and {{x}} others': 'and {{x}} others',
|
'and {{x}} others': 'and {{x}} others',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble is not responsible for what happens if you zap yourself. Proceed at your own risk. ЁЯШЙтЪб',
|
'Smesh is not responsible for what happens if you zap yourself. Proceed at your own risk. ЁЯШЙтЪб',
|
||||||
'Emoji Pack': 'Emoji Pack',
|
'Emoji Pack': 'Emoji Pack',
|
||||||
'Emoji pack added': 'Emoji pack added',
|
'Emoji pack added': 'Emoji pack added',
|
||||||
'Add emoji pack failed': 'Add emoji pack failed',
|
'Add emoji pack failed': 'Add emoji pack failed',
|
||||||
@@ -586,6 +569,58 @@ export default {
|
|||||||
'Special Follow': 'Special Follow',
|
'Special Follow': 'Special Follow',
|
||||||
'Unfollow Special': 'Unfollow Special',
|
'Unfollow Special': 'Unfollow Special',
|
||||||
'Personal Feeds': 'Personal Feeds',
|
'Personal Feeds': 'Personal Feeds',
|
||||||
'Relay Feeds': 'Relay Feeds'
|
'Relay Feeds': 'Relay Feeds',
|
||||||
|
'Create Highlight': 'Create Highlight',
|
||||||
|
'Write your thoughts about this highlight...': 'Write your thoughts about this highlight...',
|
||||||
|
'Publish Highlight': 'Publish Highlight',
|
||||||
|
'Show replies': 'Show replies',
|
||||||
|
'Hide replies': 'Hide replies',
|
||||||
|
'Welcome to Smesh!': 'Welcome to Smesh!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!',
|
||||||
|
'Search Users': 'Search Users',
|
||||||
|
'Create New Account': 'Create New Account',
|
||||||
|
Important: 'Important',
|
||||||
|
'Generate Your Account': 'Generate Your Account',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'Your private key IS your account. Keep it safe!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'In Nostr, your private key IS your account. If you lose your account forever.',
|
||||||
|
'Your Private Key': 'Your Private Key',
|
||||||
|
'Generate new key': 'Generate new key',
|
||||||
|
'Download Backup File': 'Download Backup File',
|
||||||
|
'Copied to Clipboard': 'Copied to Clipboard',
|
||||||
|
'Copy to Clipboard': 'Copy to Clipboard',
|
||||||
|
'I already saved my private key securely.': 'I already saved my private key securely.',
|
||||||
|
'Almost Done!': 'Almost Done!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'Set a password to encrypt your key, or skip to finish',
|
||||||
|
'Password Protection (Optional)': 'Password Protection (Optional)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.',
|
||||||
|
'Password (Optional)': 'Password (Optional)',
|
||||||
|
'Enter password or leave empty to skip': 'Enter password or leave empty to skip',
|
||||||
|
'Confirm Password': 'Confirm Password',
|
||||||
|
'Re-enter password': 'Re-enter password',
|
||||||
|
'Passwords do not match': 'Passwords do not match',
|
||||||
|
'Finish Signup': 'Finish Signup',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': 'Create Your Nostr Account',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'Generate your unique private key. This is your digital identity.',
|
||||||
|
'Critical: Save Your Private Key': 'Critical: Save Your Private Key',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.',
|
||||||
|
'I have safely backed up my private key': 'I have safely backed up my private key',
|
||||||
|
'Secure Your Account': 'Secure Your Account',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'Add an extra layer of protection with a password',
|
||||||
|
'Password Protection (Recommended)': 'Password Protection (Recommended)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.',
|
||||||
|
'Create a password (or skip)': 'Create a password (or skip)',
|
||||||
|
'Enter your password again': 'Enter your password again',
|
||||||
|
'Complete Signup': 'Complete Signup',
|
||||||
|
Recommended: 'Recommended'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
'Add an Account': 'Agregar una cuenta',
|
'Add an Account': 'Agregar una cuenta',
|
||||||
'More options': 'M├бs opciones',
|
'More options': 'M├бs opciones',
|
||||||
'Add client tag': 'Agregar etiqueta de cliente',
|
'Add client tag': 'Agregar etiqueta de cliente',
|
||||||
'Show others this was sent via Jumble': 'Mostrar a otros que esto se envi├│ v├нa Jumble',
|
'Show others this was sent via Smesh': 'Mostrar a otros que esto se envi├│ v├нa Smesh',
|
||||||
'Are you sure you want to logout?': '┬┐Est├бs seguro de que deseas cerrar sesi├│n?',
|
'Are you sure you want to logout?': '┬┐Est├бs seguro de que deseas cerrar sesi├│n?',
|
||||||
'relay sets': 'conjuntos de rel├йs',
|
'relay sets': 'conjuntos de rel├йs',
|
||||||
edit: 'editar',
|
edit: 'editar',
|
||||||
@@ -199,9 +199,9 @@ export default {
|
|||||||
All: 'Todo',
|
All: 'Todo',
|
||||||
Reactions: 'Reacciones',
|
Reactions: 'Reacciones',
|
||||||
Zaps: 'Zaps',
|
Zaps: 'Zaps',
|
||||||
'Enjoying Jumble?': '┬┐Te gusta Jumble?',
|
'Enjoying Smesh?': '┬┐Te gusta Smesh?',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'┬бTu donaci├│n me ayuda a mantener y mejorar Jumble! ЁЯШК',
|
'┬бTu donaci├│n me ayuda a mantener y mejorar Smesh! ЁЯШК',
|
||||||
'Earlier notifications': 'Notificaciones anteriores',
|
'Earlier notifications': 'Notificaciones anteriores',
|
||||||
'Temporarily display this note': 'Mostrar esta nota temporalmente',
|
'Temporarily display this note': 'Mostrar esta nota temporalmente',
|
||||||
buttonFollowing: 'Siguiendo',
|
buttonFollowing: 'Siguiendo',
|
||||||
@@ -255,7 +255,7 @@ export default {
|
|||||||
Translation: 'Traducci├│n',
|
Translation: 'Traducci├│n',
|
||||||
Balance: 'Saldo',
|
Balance: 'Saldo',
|
||||||
characters: 'caracteres',
|
characters: 'caracteres',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}',
|
'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}',
|
||||||
'Top up': 'Recargar',
|
'Top up': 'Recargar',
|
||||||
'Will receive: {n} characters': 'Recibir├бs: {{n}} caracteres',
|
'Will receive: {n} characters': 'Recibir├бs: {{n}} caracteres',
|
||||||
@@ -389,6 +389,7 @@ export default {
|
|||||||
'reacted to your note': 'reaccion├│ a tu nota',
|
'reacted to your note': 'reaccion├│ a tu nota',
|
||||||
'reposted your note': 'reposte├│ tu nota',
|
'reposted your note': 'reposte├│ tu nota',
|
||||||
'zapped your note': 'zappe├│ tu nota',
|
'zapped your note': 'zappe├│ tu nota',
|
||||||
|
'highlighted your note': 'destac├│ tu nota',
|
||||||
'zapped you': 'te zappe├│',
|
'zapped you': 'te zappe├│',
|
||||||
'Mark as read': 'Marcar como le├нdo',
|
'Mark as read': 'Marcar como le├нdo',
|
||||||
Report: 'Reportar',
|
Report: 'Reportar',
|
||||||
@@ -496,14 +497,14 @@ export default {
|
|||||||
Remote: 'Remoto',
|
Remote: 'Remoto',
|
||||||
'Encrypted Key': 'Clave privada cifrada',
|
'Encrypted Key': 'Clave privada cifrada',
|
||||||
'Private Key': 'Clave privada',
|
'Private Key': 'Clave privada',
|
||||||
'Welcome to Jumble': 'Bienvenido a Jumble',
|
'Welcome to Smesh': 'Bienvenido a Smesh',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble es un cliente enfocado en explorar relays. Comienza explorando relays interesantes o inicia sesi├│n para ver tu feed de seguidos.',
|
'Smesh es un cliente enfocado en explorar relays. Comienza explorando relays interesantes o inicia sesi├│n para ver tu feed de seguidos.',
|
||||||
'Explore Relays': 'Explorar Relays',
|
'Explore Relays': 'Explorar Relays',
|
||||||
'Choose a feed': 'Elige un feed',
|
'Choose a feed': 'Elige un feed',
|
||||||
'and {{x}} others': 'y {{x}} otros',
|
'and {{x}} others': 'y {{x}} otros',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. ЁЯШЙтЪб',
|
'Smesh no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. ЁЯШЙтЪб',
|
||||||
'Emoji Pack': 'Paquete de Emojis',
|
'Emoji Pack': 'Paquete de Emojis',
|
||||||
'Emoji pack added': 'Paquete de emojis a├▒adido',
|
'Emoji pack added': 'Paquete de emojis a├▒adido',
|
||||||
'Add emoji pack failed': 'Error al a├▒adir paquete de emojis',
|
'Add emoji pack failed': 'Error al a├▒adir paquete de emojis',
|
||||||
@@ -595,6 +596,59 @@ export default {
|
|||||||
'Special Follow': 'Seguir Especial',
|
'Special Follow': 'Seguir Especial',
|
||||||
'Unfollow Special': 'Dejar de Seguir Especial',
|
'Unfollow Special': 'Dejar de Seguir Especial',
|
||||||
'Personal Feeds': 'Feeds Personales',
|
'Personal Feeds': 'Feeds Personales',
|
||||||
'Relay Feeds': 'Feeds de Relays'
|
'Relay Feeds': 'Feeds de Relays',
|
||||||
|
'Create Highlight': 'Crear Resaltado',
|
||||||
|
'Write your thoughts about this highlight...':
|
||||||
|
'Escribe tus pensamientos sobre este resaltado...',
|
||||||
|
'Publish Highlight': 'Publicar Resaltado',
|
||||||
|
'Show replies': 'Mostrar respuestas',
|
||||||
|
'Hide replies': 'Ocultar respuestas',
|
||||||
|
'Welcome to Smesh!': '┬бBienvenido a Smesh!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'Tu feed est├б vac├нo porque a├║n no sigues a nadie. ┬бComienza explorando contenido interesante y siguiendo a los usuarios que te gusten!',
|
||||||
|
'Search Users': 'Buscar Usuarios',
|
||||||
|
'Create New Account': 'Crear nueva cuenta',
|
||||||
|
Important: 'Importante',
|
||||||
|
'Generate Your Account': 'Generar tu cuenta',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'┬бTu clave privada ES tu cuenta. Mantenla segura!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'En Nostr, tu clave privada ES tu cuenta. Si pierdes tu clave privada, pierdes tu cuenta para siempre.',
|
||||||
|
'Your Private Key': 'Tu clave privada',
|
||||||
|
'Generate new key': 'Generar nueva clave',
|
||||||
|
'Download Backup File': 'Descargar archivo de respaldo',
|
||||||
|
'Copied to Clipboard': 'Copiado al portapapeles',
|
||||||
|
'Copy to Clipboard': 'Copiar al portapapeles',
|
||||||
|
'I already saved my private key securely.': 'Ya guard├й mi clave privada de forma segura.',
|
||||||
|
'Almost Done!': '┬бCasi terminado!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'Establece una contrase├▒a para cifrar tu clave, o om├нtela para finalizar',
|
||||||
|
'Password Protection (Optional)': 'Protecci├│n con contrase├▒a (opcional)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'Establecer una contrase├▒a cifra tu clave privada en este navegador. Puedes omitir este paso, pero recomendamos establecer una para mayor seguridad.',
|
||||||
|
'Password (Optional)': 'Contrase├▒a (opcional)',
|
||||||
|
'Enter password or leave empty to skip': 'Ingresa una contrase├▒a o d├йjalo vac├нo para omitir',
|
||||||
|
'Confirm Password': 'Confirmar contrase├▒a',
|
||||||
|
'Re-enter password': 'Vuelve a ingresar la contrase├▒a',
|
||||||
|
'Passwords do not match': 'Las contrase├▒as no coinciden',
|
||||||
|
'Finish Signup': 'Finalizar registro',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': 'Crea tu cuenta de Nostr',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'Genera tu clave privada ├║nica. Esta es tu identidad digital.',
|
||||||
|
'Critical: Save Your Private Key': 'Cr├нtico: Guarda tu clave privada',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'Tu clave privada ES tu cuenta. No hay recuperaci├│n de contrase├▒a. Si la pierdes, perder├бs tu cuenta para siempre. Por favor, gu├бrdala en un lugar seguro.',
|
||||||
|
'I have safely backed up my private key': 'He respaldado mi clave privada de forma segura',
|
||||||
|
'Secure Your Account': 'Asegura tu cuenta',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'A├▒ade una capa adicional de protecci├│n con una contrase├▒a',
|
||||||
|
'Password Protection (Recommended)': 'Protecci├│n con contrase├▒a (recomendado)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'A├▒ade una contrase├▒a para cifrar tu clave privada en este navegador. Esto es opcional pero muy recomendado para mayor seguridad.',
|
||||||
|
'Create a password (or skip)': 'Crear una contrase├▒a (o saltar)',
|
||||||
|
'Enter your password again': 'Ingresa tu contrase├▒a nuevamente',
|
||||||
|
'Complete Signup': 'Completar registro',
|
||||||
|
Recommended: 'Recomendado'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
'Add an Account': '╪з┘Б╪▓┘И╪п┘Ж ╪н╪│╪з╪и',
|
'Add an Account': '╪з┘Б╪▓┘И╪п┘Ж ╪н╪│╪з╪и',
|
||||||
'More options': '┌п╪▓█М┘Ж┘ЗтАМ┘З╪з█М ╪и█М╪┤╪к╪▒',
|
'More options': '┌п╪▓█М┘Ж┘ЗтАМ┘З╪з█М ╪и█М╪┤╪к╪▒',
|
||||||
'Add client tag': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪▒┌Ж╪│╪и ┌й┘Д╪з█М┘Ж╪к',
|
'Add client tag': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪▒┌Ж╪│╪и ┌й┘Д╪з█М┘Ж╪к',
|
||||||
'Show others this was sent via Jumble': '╪и┘З ╪п█М┌п╪▒╪з┘Ж ┘Ж╪┤╪з┘Ж ╪п┘З█М╪п ┌й┘З ╪з╪▓ ╪╖╪▒█М┘В Jumble ╪з╪▒╪│╪з┘Д ╪┤╪п┘З',
|
'Show others this was sent via Smesh': '╪и┘З ╪п█М┌п╪▒╪з┘Ж ┘Ж╪┤╪з┘Ж ╪п┘З█М╪п ┌й┘З ╪з╪▓ ╪╖╪▒█М┘В Smesh ╪з╪▒╪│╪з┘Д ╪┤╪п┘З',
|
||||||
'Are you sure you want to logout?': '╪в█М╪з ┘Е╪╖┘Е╪ж┘Ж ┘З╪│╪к█М╪п ┌й┘З ┘Е█МтАМ╪о┘И╪з┘З█М╪п ╪о╪з╪▒╪м ╪┤┘И█М╪п╪Я',
|
'Are you sure you want to logout?': '╪в█М╪з ┘Е╪╖┘Е╪ж┘Ж ┘З╪│╪к█М╪п ┌й┘З ┘Е█МтАМ╪о┘И╪з┘З█М╪п ╪о╪з╪▒╪м ╪┤┘И█М╪п╪Я',
|
||||||
'relay sets': '┘Е╪м┘Е┘И╪╣┘ЗтАМ┘З╪з█М ╪▒┘Д┘З',
|
'relay sets': '┘Е╪м┘Е┘И╪╣┘ЗтАМ┘З╪з█М ╪▒┘Д┘З',
|
||||||
edit: '┘И█М╪▒╪з█М╪┤',
|
edit: '┘И█М╪▒╪з█М╪┤',
|
||||||
@@ -197,9 +197,9 @@ export default {
|
|||||||
All: '┘З┘Е┘З',
|
All: '┘З┘Е┘З',
|
||||||
Reactions: '┘И╪з┌й┘Ж╪┤тАМ┘З╪з',
|
Reactions: '┘И╪з┌й┘Ж╪┤тАМ┘З╪з',
|
||||||
Zaps: '╪▓┘╛тАМ┘З╪з',
|
Zaps: '╪▓┘╛тАМ┘З╪з',
|
||||||
'Enjoying Jumble?': '╪з╪▓ Jumble ┘Д╪░╪к ┘Е█МтАМ╪и╪▒█М╪п╪Я',
|
'Enjoying Smesh?': '╪з╪▓ Smesh ┘Д╪░╪к ┘Е█МтАМ╪и╪▒█М╪п╪Я',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'┌й┘Е┌й ┘Е╪з┘Д█М ╪┤┘Е╪з ╪и┘З ┘Е┘Ж ╪п╪▒ ┘Ж┌п┘З╪п╪з╪▒█М Jumble ┘И ╪и┘З╪к╪▒ ┌й╪▒╪п┘Ж ╪в┘Ж ┌й┘Е┌й ┘Е█МтАМ┌й┘Ж╪п! ЁЯШК',
|
'┌й┘Е┌й ┘Е╪з┘Д█М ╪┤┘Е╪з ╪и┘З ┘Е┘Ж ╪п╪▒ ┘Ж┌п┘З╪п╪з╪▒█М Smesh ┘И ╪и┘З╪к╪▒ ┌й╪▒╪п┘Ж ╪в┘Ж ┌й┘Е┌й ┘Е█МтАМ┌й┘Ж╪п! ЁЯШК',
|
||||||
'Earlier notifications': '╪з╪╣┘Д╪з┘ЖтАМ┘З╪з█М ┘В╪и┘Д█М',
|
'Earlier notifications': '╪з╪╣┘Д╪з┘ЖтАМ┘З╪з█М ┘В╪и┘Д█М',
|
||||||
'Temporarily display this note': '┘Ж┘Е╪з█М╪┤ ┘Е┘И┘В╪к ╪з█М┘Ж █М╪з╪п╪п╪з╪┤╪к',
|
'Temporarily display this note': '┘Ж┘Е╪з█М╪┤ ┘Е┘И┘В╪к ╪з█М┘Ж █М╪з╪п╪п╪з╪┤╪к',
|
||||||
buttonFollowing: '╪п┘Ж╪и╪з┘Д ┘Е█МтАМ┌й┘Ж┘Е',
|
buttonFollowing: '╪п┘Ж╪и╪з┘Д ┘Е█МтАМ┌й┘Ж┘Е',
|
||||||
@@ -252,7 +252,7 @@ export default {
|
|||||||
Translation: '╪к╪▒╪м┘Е┘З',
|
Translation: '╪к╪▒╪м┘Е┘З',
|
||||||
Balance: '┘Е┘И╪м┘И╪п█М',
|
Balance: '┘Е┘И╪м┘И╪п█М',
|
||||||
characters: '┌й╪з╪▒╪з┌й╪к╪▒',
|
characters: '┌й╪з╪▒╪з┌й╪к╪▒',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з╪▓ ╪з█М┘Ж ┌й┘Д█М╪п API ╪п╪▒ ┘З╪▒ ╪м╪з█М ╪п█М┌п╪▒█М ┌й┘З ╪з╪▓ LibreTranslate ┘╛╪┤╪к█М╪и╪з┘Ж█М ┘Е█МтАМ┌й┘Ж╪п ╪з╪│╪к┘Б╪з╪п┘З ┌й┘Ж█М╪п. ╪в╪п╪▒╪│ ╪│╪▒┘И█М╪│ {{serviceUrl}} ╪з╪│╪к',
|
'┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з╪▓ ╪з█М┘Ж ┌й┘Д█М╪п API ╪п╪▒ ┘З╪▒ ╪м╪з█М ╪п█М┌п╪▒█М ┌й┘З ╪з╪▓ LibreTranslate ┘╛╪┤╪к█М╪и╪з┘Ж█М ┘Е█МтАМ┌й┘Ж╪п ╪з╪│╪к┘Б╪з╪п┘З ┌й┘Ж█М╪п. ╪в╪п╪▒╪│ ╪│╪▒┘И█М╪│ {{serviceUrl}} ╪з╪│╪к',
|
||||||
'Top up': '╪┤╪з╪▒┌Ш',
|
'Top up': '╪┤╪з╪▒┌Ш',
|
||||||
'Will receive: {n} characters': '╪п╪▒█М╪з┘Б╪к ╪о┘И╪з┘З█М╪п ┌й╪▒╪п: {{n}} ┌й╪з╪▒╪з┌й╪к╪▒',
|
'Will receive: {n} characters': '╪п╪▒█М╪з┘Б╪к ╪о┘И╪з┘З█М╪п ┌й╪▒╪п: {{n}} ┌й╪з╪▒╪з┌й╪к╪▒',
|
||||||
@@ -385,6 +385,7 @@ export default {
|
|||||||
'reacted to your note': '╪и┘З █М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ┘И╪з┌й┘Ж╪┤ ┘Ж╪┤╪з┘Ж ╪п╪з╪п',
|
'reacted to your note': '╪и┘З █М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ┘И╪з┌й┘Ж╪┤ ┘Ж╪┤╪з┘Ж ╪п╪з╪п',
|
||||||
'reposted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪з╪▓┘Ж╪┤╪▒ ┌й╪▒╪п',
|
'reposted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪з╪▓┘Ж╪┤╪▒ ┌й╪▒╪п',
|
||||||
'zapped your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
|
'zapped your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
|
||||||
|
'highlighted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪▒╪м╪│╪к┘З ┌й╪▒╪п',
|
||||||
'zapped you': '╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
|
'zapped you': '╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
|
||||||
'Mark as read': '╪╣┘Д╪з┘Е╪ктАМ┌п╪░╪з╪▒█М ╪и┘З ╪╣┘Ж┘И╪з┘Ж ╪о┘И╪з┘Ж╪п┘З ╪┤╪п┘З',
|
'Mark as read': '╪╣┘Д╪з┘Е╪ктАМ┌п╪░╪з╪▒█М ╪и┘З ╪╣┘Ж┘И╪з┘Ж ╪о┘И╪з┘Ж╪п┘З ╪┤╪п┘З',
|
||||||
Report: '┌п╪▓╪з╪▒╪┤',
|
Report: '┌п╪▓╪з╪▒╪┤',
|
||||||
@@ -491,14 +492,14 @@ export default {
|
|||||||
Remote: '╪з╪▓ ╪▒╪з┘З ╪п┘И╪▒',
|
Remote: '╪з╪▓ ╪▒╪з┘З ╪п┘И╪▒',
|
||||||
'Encrypted Key': '╪▒┘Е╪▓┌п╪░╪з╪▒█М ╪┤╪п┘З ┌й┘Д█М╪п',
|
'Encrypted Key': '╪▒┘Е╪▓┌п╪░╪з╪▒█М ╪┤╪п┘З ┌й┘Д█М╪п',
|
||||||
'Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М',
|
'Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М',
|
||||||
'Welcome to Jumble': '╪и┘З Jumble ╪о┘И╪┤ ╪в┘Е╪п█М╪п',
|
'Welcome to Smesh': '╪и┘З Smesh ╪о┘И╪┤ ╪в┘Е╪п█М╪п',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble █М┌й ┌й┘Д╪з█М┘Ж╪к ┘Е╪к┘Е╪▒┌й╪▓ ╪и╪▒ ┘Е╪▒┘И╪▒ ╪▒┘Д┘ЗтАМ┘З╪з╪│╪к. ╪и╪з ┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з█М ╪м╪з┘Д╪и ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п █М╪з ┘И╪з╪▒╪п ╪┤┘И█М╪п ╪к╪з ┘Б█М╪п ╪п┘Ж╪и╪з┘ДтАМ┌й┘Ж┘Ж╪п┘ЗтАМ┘З╪з█М ╪о┘И╪п ╪▒╪з ┘Е╪┤╪з┘З╪п┘З ┌й┘Ж█М╪п.',
|
'Smesh █М┌й ┌й┘Д╪з█М┘Ж╪к ┘Е╪к┘Е╪▒┌й╪▓ ╪и╪▒ ┘Е╪▒┘И╪▒ ╪▒┘Д┘ЗтАМ┘З╪з╪│╪к. ╪и╪з ┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з█М ╪м╪з┘Д╪и ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п █М╪з ┘И╪з╪▒╪п ╪┤┘И█М╪п ╪к╪з ┘Б█М╪п ╪п┘Ж╪и╪з┘ДтАМ┌й┘Ж┘Ж╪п┘ЗтАМ┘З╪з█М ╪о┘И╪п ╪▒╪з ┘Е╪┤╪з┘З╪п┘З ┌й┘Ж█М╪п.',
|
||||||
'Explore Relays': '┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з',
|
'Explore Relays': '┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з',
|
||||||
'Choose a feed': '█М┌й ┘Б█М╪п ╪з┘Ж╪к╪о╪з╪и ┌й┘Ж█М╪п',
|
'Choose a feed': '█М┌й ┘Б█М╪п ╪з┘Ж╪к╪о╪з╪и ┌й┘Ж█М╪п',
|
||||||
'and {{x}} others': '┘И {{x}} ╪п█М┌п╪▒',
|
'and {{x}} others': '┘И {{x}} ╪п█М┌п╪▒',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble ┘Е╪│╪ж┘И┘Д█М╪к█М ╪п╪▒ ┘В╪и╪з┘Д ╪з╪к┘Б╪з┘В╪з╪к█М ┌й┘З ╪п╪▒ ╪╡┘И╪▒╪к ╪з╪▒╪│╪з┘Д zap ╪и┘З ╪о┘И╪п╪к╪з┘Ж ┘Е█МтАМ╪з┘Б╪к╪п ┘Ж╪п╪з╪▒╪п. ╪и╪з ┘Е╪│╪ж┘И┘Д█М╪к ╪о┘И╪п ╪з╪п╪з┘Е┘З ╪п┘З█М╪п. ЁЯШЙтЪб',
|
'Smesh ┘Е╪│╪ж┘И┘Д█М╪к█М ╪п╪▒ ┘В╪и╪з┘Д ╪з╪к┘Б╪з┘В╪з╪к█М ┌й┘З ╪п╪▒ ╪╡┘И╪▒╪к ╪з╪▒╪│╪з┘Д zap ╪и┘З ╪о┘И╪п╪к╪з┘Ж ┘Е█МтАМ╪з┘Б╪к╪п ┘Ж╪п╪з╪▒╪п. ╪и╪з ┘Е╪│╪ж┘И┘Д█М╪к ╪о┘И╪п ╪з╪п╪з┘Е┘З ╪п┘З█М╪п. ЁЯШЙтЪб',
|
||||||
'Emoji Pack': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М',
|
'Emoji Pack': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М',
|
||||||
'Emoji pack added': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ╪з╪╢╪з┘Б┘З ╪┤╪п',
|
'Emoji pack added': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ╪з╪╢╪з┘Б┘З ╪┤╪п',
|
||||||
'Add emoji pack failed': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ┘Ж╪з┘Е┘И┘Б┘В ╪и┘И╪п',
|
'Add emoji pack failed': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ┘Ж╪з┘Е┘И┘Б┘В ╪и┘И╪п',
|
||||||
@@ -589,6 +590,60 @@ export default {
|
|||||||
'Special Follow': '╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
|
'Special Follow': '╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
|
||||||
'Unfollow Special': '┘Д╪║┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
|
'Unfollow Special': '┘Д╪║┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
|
||||||
'Personal Feeds': '┘Б█М╪п┘З╪з█М ╪┤╪о╪╡█М',
|
'Personal Feeds': '┘Б█М╪п┘З╪з█М ╪┤╪о╪╡█М',
|
||||||
'Relay Feeds': '┘Б█М╪п┘З╪з█М ╪▒┘Д┘З'
|
'Relay Feeds': '┘Б█М╪п┘З╪з█М ╪▒┘Д┘З',
|
||||||
|
'Create Highlight': '╪з█М╪м╪з╪п ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М',
|
||||||
|
'Write your thoughts about this highlight...': '┘Ж╪╕╪▒╪з╪к ╪о┘И╪п ╪▒╪з ╪п╪▒╪и╪з╪▒┘З ╪з█М┘Ж ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М ╪и┘Ж┘И█М╪│█М╪п...',
|
||||||
|
'Publish Highlight': '╪з┘Ж╪к╪┤╪з╪▒ ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М',
|
||||||
|
'Show replies': '┘Ж┘Е╪з█М╪┤ ┘╛╪з╪│╪отАМ┘З╪з',
|
||||||
|
'Hide replies': '┘╛┘Ж┘З╪з┘Ж ┌й╪▒╪п┘Ж ┘╛╪з╪│╪отАМ┘З╪з',
|
||||||
|
'Welcome to Smesh!': '╪и┘З Smesh ╪о┘И╪┤ ╪в┘Е╪п█М╪п!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'┘Б█М╪п ╪┤┘Е╪з ╪о╪з┘Д█М ╪з╪│╪к ╪▓█М╪▒╪з ┘З┘Ж┘И╪▓ ┌й╪│█М ╪▒╪з ╪п┘Ж╪и╪з┘Д ┘Ж┘Е█МтАМ┌й┘Ж█М╪п. ╪и╪з ┌й╪з┘И╪┤ ┘Е╪н╪к┘И╪з█М ╪м╪з┘Д╪и ┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┌й╪з╪▒╪и╪▒╪з┘Ж█М ┌й┘З ╪п┘И╪│╪к ╪п╪з╪▒█М╪п ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п!',
|
||||||
|
'Search Users': '╪м╪│╪к╪м┘И█М ┌й╪з╪▒╪и╪▒╪з┘Ж',
|
||||||
|
'Create New Account': '╪з█М╪м╪з╪п ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪м╪п█М╪п',
|
||||||
|
Important: '┘Е┘З┘Е',
|
||||||
|
'Generate Your Account': '╪з█М╪м╪з╪п ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ┘З┘Е╪з┘Ж ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪┤┘Е╪з╪│╪к. ╪в┘Ж ╪▒╪з ╪з█М┘Е┘Ж ┘Ж┌п┘З ╪п╪з╪▒█М╪п!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'╪п╪▒ Nostr╪М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ┘З┘Е╪з┘Ж ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪┤┘Е╪з╪│╪к. ╪з┌п╪▒ ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ┌п┘Е ┌й┘Ж█М╪п╪М ╪и╪▒╪з█М ┘З┘Е█М╪┤┘З ╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪з╪▓ ╪п╪│╪к ┘Е█МтАМ╪п┘З█М╪п.',
|
||||||
|
'Your Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з',
|
||||||
|
'Generate new key': '╪з█М╪м╪з╪п ┌й┘Д█М╪п ╪м╪п█М╪п',
|
||||||
|
'Download Backup File': '╪п╪з┘Ж┘Д┘И╪п ┘Б╪з█М┘Д ┘╛╪┤╪к█М╪и╪з┘Ж',
|
||||||
|
'Copied to Clipboard': '╪п╪▒ ┌й┘Д█М┘╛тАМ╪и┘И╪▒╪п ┌й┘╛█М ╪┤╪п',
|
||||||
|
'Copy to Clipboard': '┌й┘╛█М ╪п╪▒ ┌й┘Д█М┘╛тАМ╪и┘И╪▒╪п',
|
||||||
|
'I already saved my private key securely.':
|
||||||
|
'┘Е┘Ж ┘В╪и┘Д╪з┘Л ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ╪и┘З ╪╖┘И╪▒ ╪з█М┘Е┘Ж ╪░╪о█М╪▒┘З ┌й╪▒╪п┘ЗтАМ╪з┘Е.',
|
||||||
|
'Almost Done!': '╪к┘В╪▒█М╪и╪з┘Л ╪к┘Е╪з┘Е ╪┤╪п!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪и╪▒╪з█М ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┌й┘Д█М╪п ╪о┘И╪п ╪к┘Ж╪╕█М┘Е ┌й┘Ж█М╪п╪М █М╪з ╪и╪▒╪з█М ┘╛╪з█М╪з┘Ж ╪п╪з╪п┘Ж ╪▒╪п ┌й┘Ж█М╪п',
|
||||||
|
'Password Protection (Optional)': '╪н┘Б╪з╪╕╪к ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪з╪о╪к█М╪з╪▒█М)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'╪к┘Ж╪╕█М┘Е ╪▒┘Е╪▓ ╪╣╪и┘И╪▒╪М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ╪▒╪з ╪п╪▒ ╪з█М┘Ж ┘Е╪▒┘И╪▒┌п╪▒ ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┘Е█МтАМ┌й┘Ж╪п. ┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з█М┘Ж ┘Е╪▒╪н┘Д┘З ╪▒╪з ╪▒╪п ┌й┘Ж█М╪п╪М ╪з┘Е╪з ┘Е╪з ╪и╪▒╪з█М ╪з┘Е┘Ж█М╪к ╪и█М╪┤╪к╪▒ ╪к┘И╪╡█М┘З ┘Е█МтАМ┌й┘Ж█М┘Е █М┌й█М ╪к┘Ж╪╕█М┘Е ┌й┘Ж█М╪п.',
|
||||||
|
'Password (Optional)': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪з╪о╪к█М╪з╪▒█М)',
|
||||||
|
'Enter password or leave empty to skip': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪▒╪з ┘И╪з╪▒╪п ┌й┘Ж█М╪п █М╪з ╪и╪▒╪з█М ╪▒╪п ┌й╪▒╪п┘Ж ╪о╪з┘Д█М ╪и┌п╪░╪з╪▒█М╪п',
|
||||||
|
'Confirm Password': '╪к╪г█М█М╪п ╪▒┘Е╪▓ ╪╣╪и┘И╪▒',
|
||||||
|
'Re-enter password': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪▒╪з ╪п┘И╪и╪з╪▒┘З ┘И╪з╪▒╪п ┌й┘Ж█М╪п',
|
||||||
|
'Passwords do not match': '╪▒┘Е╪▓┘З╪з█М ╪╣╪и┘И╪▒ ┘Е╪╖╪з╪и┘В╪к ┘Ж╪п╪з╪▒┘Ж╪п',
|
||||||
|
'Finish Signup': '┘╛╪з█М╪з┘Ж ╪л╪и╪ктАМ┘Ж╪з┘Е',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': '╪н╪│╪з╪и Nostr ╪о┘И╪п ╪▒╪з ╪з█М╪м╪з╪п ┌й┘Ж█М╪п',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ┘Е┘Ж╪н╪╡╪▒ ╪и┘З ┘Б╪▒╪п ╪о┘И╪п ╪▒╪з ╪з█М╪м╪з╪п ┌й┘Ж█М╪п. ╪з█М┘Ж ┘З┘И█М╪к ╪п█М╪м█М╪к╪з┘Д ╪┤┘Е╪з╪│╪к.',
|
||||||
|
'Critical: Save Your Private Key': '╪н█М╪з╪к█М: ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ╪░╪о█М╪▒┘З ┌й┘Ж█М╪п',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ╪н╪│╪з╪и ╪┤┘Е╪з╪│╪к. ╪и╪з╪▓█М╪з╪и█М ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ┘И╪м┘И╪п ┘Ж╪п╪з╪▒╪п. ╪з┌п╪▒ ╪в┘Ж ╪▒╪з ┌п┘Е ┌й┘Ж█М╪п╪М ╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪и╪▒╪з█М ┘З┘Е█М╪┤┘З ╪з╪▓ ╪п╪│╪к ╪о┘И╪з┘З█М╪п ╪п╪з╪п. ┘Д╪╖┘Б╪з┘Л ╪в┘Ж ╪▒╪з ╪п╪▒ ┘Е┌й╪з┘Ж█М ╪з┘Е┘Ж ╪░╪о█М╪▒┘З ┌й┘Ж█М╪п.',
|
||||||
|
'I have safely backed up my private key':
|
||||||
|
'┘Е┘Ж ╪и┘З ╪╖┘И╪▒ ╪з█М┘Е┘Ж ╪з╪▓ ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ┘Ж╪│╪о┘З ┘╛╪┤╪к█М╪и╪з┘Ж ╪к┘З█М┘З ┌й╪▒╪п┘ЗтАМ╪з┘Е',
|
||||||
|
'Secure Your Account': '╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪з█М┘Е┘Ж ┌й┘Ж█М╪п',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'█М┌й ┘Д╪з█М┘З ╪н┘Б╪з╪╕╪к█М ╪з╪╢╪з┘Б█М ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪з╪╢╪з┘Б┘З ┌й┘Ж█М╪п',
|
||||||
|
'Password Protection (Recommended)': '╪н┘Б╪з╪╕╪к ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪к┘И╪╡█М┘З ╪┤╪п┘З)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪и╪▒╪з█М ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪п╪▒ ╪з█М┘Ж ┘Е╪▒┘И╪▒┌п╪▒ ╪з╪╢╪з┘Б┘З ┌й┘Ж█М╪п. ╪з█М┘Ж ╪з╪о╪к█М╪з╪▒█М ╪з╪│╪к ╪з┘Е╪з ╪и╪▒╪з█М ╪з┘Е┘Ж█М╪к ╪и┘З╪к╪▒ ╪и┘З ╪┤╪п╪к ╪к┘И╪╡█М┘З ┘Е█МтАМ╪┤┘И╪п.',
|
||||||
|
'Create a password (or skip)': '█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪з█М╪м╪з╪п ┌й┘Ж█М╪п (█М╪з ╪▒╪п ┌й┘Ж█М╪п)',
|
||||||
|
'Enter your password again': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪о┘И╪п ╪▒╪з ╪п┘И╪и╪з╪▒┘З ┘И╪з╪▒╪п ┌й┘Ж█М╪п',
|
||||||
|
'Complete Signup': '╪к┌й┘Е█М┘Д ╪л╪и╪ктАМ┘Ж╪з┘Е',
|
||||||
|
Recommended: '╪к┘И╪╡█М┘З ╪┤╪п┘З'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
'Add an Account': 'Ajouter un compte',
|
'Add an Account': 'Ajouter un compte',
|
||||||
'More options': "Plus d'options",
|
'More options': "Plus d'options",
|
||||||
'Add client tag': 'Ajouter une ├йtiquette client',
|
'Add client tag': 'Ajouter une ├йtiquette client',
|
||||||
'Show others this was sent via Jumble': 'Montrer aux autres que cela a ├йt├й envoy├й via Jumble',
|
'Show others this was sent via Smesh': 'Montrer aux autres que cela a ├йt├й envoy├й via Smesh',
|
||||||
'Are you sure you want to logout?': '├Кtes-vous s├╗r de vouloir vous d├йconnecter ?',
|
'Are you sure you want to logout?': '├Кtes-vous s├╗r de vouloir vous d├йconnecter ?',
|
||||||
'relay sets': 'groupes de relais',
|
'relay sets': 'groupes de relais',
|
||||||
edit: 'modifier',
|
edit: 'modifier',
|
||||||
@@ -198,9 +198,9 @@ export default {
|
|||||||
All: 'Tous',
|
All: 'Tous',
|
||||||
Reactions: 'R├йactions',
|
Reactions: 'R├йactions',
|
||||||
Zaps: 'Zaps',
|
Zaps: 'Zaps',
|
||||||
'Enjoying Jumble?': 'Vous appr├йciez Jumble ?',
|
'Enjoying Smesh?': 'Vous appr├йciez Smesh ?',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
"Votre don m'aide ├а maintenir Jumble et ├а l'am├йliorer ! ЁЯШК",
|
"Votre don m'aide ├а maintenir Smesh et ├а l'am├йliorer ! ЁЯШК",
|
||||||
'Earlier notifications': 'Notifications ant├йrieures',
|
'Earlier notifications': 'Notifications ant├йrieures',
|
||||||
'Temporarily display this note': 'Afficher temporairement cette note',
|
'Temporarily display this note': 'Afficher temporairement cette note',
|
||||||
buttonFollowing: 'Suivi',
|
buttonFollowing: 'Suivi',
|
||||||
@@ -255,7 +255,7 @@ export default {
|
|||||||
Translation: 'Traduction',
|
Translation: 'Traduction',
|
||||||
Balance: 'Solde',
|
Balance: 'Solde',
|
||||||
characters: 'caract├иres',
|
characters: 'caract├иres',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'Vous pouvez utiliser cette cl├й API ailleurs qui prend en charge LibreTranslate. LтАЩURL du service est {{serviceUrl}}',
|
'Vous pouvez utiliser cette cl├й API ailleurs qui prend en charge LibreTranslate. LтАЩURL du service est {{serviceUrl}}',
|
||||||
'Top up': 'Recharger',
|
'Top up': 'Recharger',
|
||||||
'Will receive: {n} characters': 'Vous recevrez : {{n}} caract├иres',
|
'Will receive: {n} characters': 'Vous recevrez : {{n}} caract├иres',
|
||||||
@@ -393,6 +393,7 @@ export default {
|
|||||||
'reacted to your note': 'a r├йagi ├а votre note',
|
'reacted to your note': 'a r├йagi ├а votre note',
|
||||||
'reposted your note': 'a repartag├й votre note',
|
'reposted your note': 'a repartag├й votre note',
|
||||||
'zapped your note': 'a zapp├й votre note',
|
'zapped your note': 'a zapp├й votre note',
|
||||||
|
'highlighted your note': 'a mis en ├йvidence votre note',
|
||||||
'zapped you': 'vous a zapp├й',
|
'zapped you': 'vous a zapp├й',
|
||||||
'Mark as read': 'Marquer comme lu',
|
'Mark as read': 'Marquer comme lu',
|
||||||
Report: 'Signaler',
|
Report: 'Signaler',
|
||||||
@@ -501,14 +502,14 @@ export default {
|
|||||||
Remote: 'Distant',
|
Remote: 'Distant',
|
||||||
'Encrypted Key': 'Cl├й chiffr├йe',
|
'Encrypted Key': 'Cl├й chiffr├йe',
|
||||||
'Private Key': 'Cl├й priv├йe',
|
'Private Key': 'Cl├й priv├йe',
|
||||||
'Welcome to Jumble': 'Bienvenue sur Jumble',
|
'Welcome to Smesh': 'Bienvenue sur Smesh',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
"Jumble est un client ax├й sur la navigation des relais. Commencez par explorer des relais int├йressants ou connectez-vous pour voir votre fil d'abonnements.",
|
"Smesh est un client ax├й sur la navigation des relais. Commencez par explorer des relais int├йressants ou connectez-vous pour voir votre fil d'abonnements.",
|
||||||
'Explore Relays': 'Explorer les relais',
|
'Explore Relays': 'Explorer les relais',
|
||||||
'Choose a feed': 'Choisir un fil',
|
'Choose a feed': 'Choisir un fil',
|
||||||
'and {{x}} others': 'et {{x}} autres',
|
'and {{x}} others': 'et {{x}} autres',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
"Jumble n'est pas responsable de ce qui se passe si vous vous zappez vous-m├кme. Proc├йdez ├а vos risques et p├йrils. ЁЯШЙтЪб",
|
"Smesh n'est pas responsable de ce qui se passe si vous vous zappez vous-m├кme. Proc├йdez ├а vos risques et p├йrils. ЁЯШЙтЪб",
|
||||||
'Emoji Pack': "Pack d'Emojis",
|
'Emoji Pack': "Pack d'Emojis",
|
||||||
'Emoji pack added': "Pack d'emojis ajout├й",
|
'Emoji pack added': "Pack d'emojis ajout├й",
|
||||||
'Add emoji pack failed': "├Йchec de l'ajout du pack d'emojis",
|
'Add emoji pack failed': "├Йchec de l'ajout du pack d'emojis",
|
||||||
@@ -598,6 +599,59 @@ export default {
|
|||||||
'Special Follow': 'Suivre Sp├йcial',
|
'Special Follow': 'Suivre Sp├йcial',
|
||||||
'Unfollow Special': 'Ne Plus Suivre Sp├йcial',
|
'Unfollow Special': 'Ne Plus Suivre Sp├йcial',
|
||||||
'Personal Feeds': 'Flux Personnels',
|
'Personal Feeds': 'Flux Personnels',
|
||||||
'Relay Feeds': 'Flux de Relais'
|
'Relay Feeds': 'Flux de Relais',
|
||||||
|
'Create Highlight': 'Cr├йer un Surlignage',
|
||||||
|
'Write your thoughts about this highlight...': '├Йcrivez vos pens├йes sur ce surlignage...',
|
||||||
|
'Publish Highlight': 'Publier le Surlignage',
|
||||||
|
'Show replies': 'Afficher les r├йponses',
|
||||||
|
'Hide replies': 'Masquer les r├йponses',
|
||||||
|
'Welcome to Smesh!': 'Bienvenue sur Smesh !',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'Votre flux est vide car vous ne suivez personne pour le moment. Commencez par explorer du contenu int├йressant et suivez les utilisateurs que vous aimez !',
|
||||||
|
'Search Users': 'Rechercher des utilisateurs',
|
||||||
|
'Create New Account': 'Cr├йer un nouveau compte',
|
||||||
|
Important: 'Important',
|
||||||
|
'Generate Your Account': 'G├йn├йrer votre compte',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'Votre cl├й priv├йe EST votre compte. Gardez-la en s├йcurit├й !',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'Dans Nostr, votre cl├й priv├йe EST votre compte. Si vous perdez votre cl├й priv├йe, vous perdez votre compte pour toujours.',
|
||||||
|
'Your Private Key': 'Votre cl├й priv├йe',
|
||||||
|
'Generate new key': 'G├йn├йrer une nouvelle cl├й',
|
||||||
|
'Download Backup File': 'T├йl├йcharger le fichier de sauvegarde',
|
||||||
|
'Copied to Clipboard': 'Copi├й dans le presse-papiers',
|
||||||
|
'Copy to Clipboard': 'Copier dans le presse-papiers',
|
||||||
|
'I already saved my private key securely.':
|
||||||
|
"J'ai d├йj├а sauvegard├й ma cl├й priv├йe en toute s├йcurit├й.",
|
||||||
|
'Almost Done!': 'Presque termin├й !',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'D├йfinissez un mot de passe pour chiffrer votre cl├й, ou ignorez pour terminer',
|
||||||
|
'Password Protection (Optional)': 'Protection par mot de passe (facultatif)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
"D├йfinir un mot de passe chiffre votre cl├й priv├йe dans ce navigateur. Vous pouvez ignorer cette ├йtape, mais nous recommandons d'en d├йfinir un pour plus de s├йcurit├й.",
|
||||||
|
'Password (Optional)': 'Mot de passe (facultatif)',
|
||||||
|
'Enter password or leave empty to skip': 'Entrez un mot de passe ou laissez vide pour ignorer',
|
||||||
|
'Confirm Password': 'Confirmer le mot de passe',
|
||||||
|
'Re-enter password': 'Ressaisissez le mot de passe',
|
||||||
|
'Passwords do not match': 'Les mots de passe ne correspondent pas',
|
||||||
|
'Finish Signup': "Terminer l'inscription",
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': 'Cr├йez votre compte Nostr',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
"G├йn├йrez votre cl├й priv├йe unique. C'est votre identit├й num├йrique.",
|
||||||
|
'Critical: Save Your Private Key': 'Critique : Sauvegardez votre cl├й priv├йe',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
"Votre cl├й priv├йe EST votre compte. Il n'y a pas de r├йcup├йration de mot de passe. Si vous la perdez, vous perdrez votre compte pour toujours. Veuillez la sauvegarder dans un endroit s├йcuris├й.",
|
||||||
|
'I have safely backed up my private key': "J'ai sauvegard├й ma cl├й priv├йe en toute s├йcurit├й",
|
||||||
|
'Secure Your Account': 'S├йcurisez votre compte',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'Ajoutez une couche de protection suppl├йmentaire avec un mot de passe',
|
||||||
|
'Password Protection (Recommended)': 'Protection par mot de passe (recommand├й)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
"Ajoutez un mot de passe pour chiffrer votre cl├й priv├йe dans ce navigateur. C'est facultatif mais fortement recommand├й pour une meilleure s├йcurit├й.",
|
||||||
|
'Create a password (or skip)': 'Cr├йez un mot de passe (ou ignorez)',
|
||||||
|
'Enter your password again': 'Entrez ├а nouveau votre mot de passe',
|
||||||
|
'Complete Signup': "Terminer l'inscription",
|
||||||
|
Recommended: 'Recommand├й'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ export default {
|
|||||||
'Add an Account': 'рдЕрдХрд╛рдЙрдВрдЯ рдЬреЛрдбрд╝реЗрдВ',
|
'Add an Account': 'рдЕрдХрд╛рдЙрдВрдЯ рдЬреЛрдбрд╝реЗрдВ',
|
||||||
'More options': 'рдЕрдзрд┐рдХ рд╡рд┐рдХрд▓реНрдк',
|
'More options': 'рдЕрдзрд┐рдХ рд╡рд┐рдХрд▓реНрдк',
|
||||||
'Add client tag': 'рдХреНрд▓рд╛рдЗрдВрдЯ рдЯреИрдЧ рдЬреЛрдбрд╝реЗрдВ',
|
'Add client tag': 'рдХреНрд▓рд╛рдЗрдВрдЯ рдЯреИрдЧ рдЬреЛрдбрд╝реЗрдВ',
|
||||||
'Show others this was sent via Jumble':
|
'Show others this was sent via Smesh':
|
||||||
'рджреВрд╕рд░реЛрдВ рдХреЛ рджрд┐рдЦрд╛рдПрдВ рдХрд┐ рдпрд╣ Jumble рдХреЗ рдорд╛рдзреНрдпрдо рд╕реЗ рднреЗрдЬрд╛ рдЧрдпрд╛ рдерд╛',
|
'рджреВрд╕рд░реЛрдВ рдХреЛ рджрд┐рдЦрд╛рдПрдВ рдХрд┐ рдпрд╣ Smesh рдХреЗ рдорд╛рдзреНрдпрдо рд╕реЗ рднреЗрдЬрд╛ рдЧрдпрд╛ рдерд╛',
|
||||||
'Are you sure you want to logout?': 'рдХреНрдпрд╛ рдЖрдк рд╡рд╛рдХрдИ рд▓реЙрдЧрдЖрдЙрдЯ рдХрд░рдирд╛ рдЪрд╛рд╣рддреЗ рд╣реИрдВ?',
|
'Are you sure you want to logout?': 'рдХреНрдпрд╛ рдЖрдк рд╡рд╛рдХрдИ рд▓реЙрдЧрдЖрдЙрдЯ рдХрд░рдирд╛ рдЪрд╛рд╣рддреЗ рд╣реИрдВ?',
|
||||||
'relay sets': 'рд░рд┐рд▓реЗ рд╕реЗрдЯ',
|
'relay sets': 'рд░рд┐рд▓реЗ рд╕реЗрдЯ',
|
||||||
edit: 'рд╕рдВрдкрд╛рджрд┐рдд рдХрд░реЗрдВ',
|
edit: 'рд╕рдВрдкрд╛рджрд┐рдд рдХрд░реЗрдВ',
|
||||||
@@ -198,9 +198,9 @@ export default {
|
|||||||
All: 'рд╕рднреА',
|
All: 'рд╕рднреА',
|
||||||
Reactions: 'рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛рдПрдВ',
|
Reactions: 'рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛рдПрдВ',
|
||||||
Zaps: 'рдЬреИрдкреНрд╕',
|
Zaps: 'рдЬреИрдкреНрд╕',
|
||||||
'Enjoying Jumble?': 'Jumble рдХрд╛ рдЖрдирдВрдж рд▓реЗ рд░рд╣реЗ рд╣реИрдВ?',
|
'Enjoying Smesh?': 'Smesh рдХрд╛ рдЖрдирдВрдж рд▓реЗ рд░рд╣реЗ рд╣реИрдВ?',
|
||||||
'Your donation helps me maintain Jumble and make it better! ЁЯШК':
|
'Your donation helps me maintain Smesh and make it better! ЁЯШК':
|
||||||
'рдЖрдкрдХрд╛ рджрд╛рди рдореБрдЭреЗ Jumble рдХреЛ рдмрдирд╛рдП рд░рдЦрдиреЗ рдФрд░ рдЗрд╕реЗ рдмреЗрд╣рддрд░ рдмрдирд╛рдиреЗ рдореЗрдВ рдорджрдж рдХрд░рддрд╛ рд╣реИ! ЁЯШК',
|
'рдЖрдкрдХрд╛ рджрд╛рди рдореБрдЭреЗ Smesh рдХреЛ рдмрдирд╛рдП рд░рдЦрдиреЗ рдФрд░ рдЗрд╕реЗ рдмреЗрд╣рддрд░ рдмрдирд╛рдиреЗ рдореЗрдВ рдорджрдж рдХрд░рддрд╛ рд╣реИ! ЁЯШК',
|
||||||
'Earlier notifications': 'рдкреБрд░рд╛рдиреА рд╕реВрдЪрдирд╛рдПрдВ',
|
'Earlier notifications': 'рдкреБрд░рд╛рдиреА рд╕реВрдЪрдирд╛рдПрдВ',
|
||||||
'Temporarily display this note': 'рдЗрд╕ рдиреЛрдЯ рдХреЛ рдЕрд╕реНрдерд╛рдпреА рд░реВрдк рд╕реЗ рдкреНрд░рджрд░реНрд╢рд┐рдд рдХрд░реЗрдВ',
|
'Temporarily display this note': 'рдЗрд╕ рдиреЛрдЯ рдХреЛ рдЕрд╕реНрдерд╛рдпреА рд░реВрдк рд╕реЗ рдкреНрд░рджрд░реНрд╢рд┐рдд рдХрд░реЗрдВ',
|
||||||
buttonFollowing: 'рдлреЙрд▓реЛ рдХрд░ рд░рд╣реЗ рд╣реИрдВ',
|
buttonFollowing: 'рдлреЙрд▓реЛ рдХрд░ рд░рд╣реЗ рд╣реИрдВ',
|
||||||
@@ -253,7 +253,7 @@ export default {
|
|||||||
Translation: 'рдЕрдиреБрд╡рд╛рдж',
|
Translation: 'рдЕрдиреБрд╡рд╛рдж',
|
||||||
Balance: 'рдмреИрд▓реЗрдВрд╕',
|
Balance: 'рдмреИрд▓реЗрдВрд╕',
|
||||||
characters: 'рдЕрдХреНрд╖рд░',
|
characters: 'рдЕрдХреНрд╖рд░',
|
||||||
jumbleTranslateApiKeyDescription:
|
smeshTranslateApiKeyDescription:
|
||||||
'рдЖрдк рдЗрд╕ API рдХреА рдХреЛ рдХрд╣реАрдВ рднреА рдЙрдкрдпреЛрдЧ рдХрд░ рд╕рдХрддреЗ рд╣реИрдВ рдЬреЛ LibreTranslate рдХрд╛ рд╕рдорд░реНрдерди рдХрд░рддрд╛ рд╣реИред рд╕реЗрд╡рд╛ URL рд╣реИ {{serviceUrl}}',
|
'рдЖрдк рдЗрд╕ API рдХреА рдХреЛ рдХрд╣реАрдВ рднреА рдЙрдкрдпреЛрдЧ рдХрд░ рд╕рдХрддреЗ рд╣реИрдВ рдЬреЛ LibreTranslate рдХрд╛ рд╕рдорд░реНрдерди рдХрд░рддрд╛ рд╣реИред рд╕реЗрд╡рд╛ URL рд╣реИ {{serviceUrl}}',
|
||||||
'Top up': 'рдЯреЙрдк рдЕрдк',
|
'Top up': 'рдЯреЙрдк рдЕрдк',
|
||||||
'Will receive: {n} characters': 'рдкреНрд░рд╛рдкреНрдд рд╣реЛрдВрдЧреЗ: {{n}} рдЕрдХреНрд╖рд░',
|
'Will receive: {n} characters': 'рдкреНрд░рд╛рдкреНрдд рд╣реЛрдВрдЧреЗ: {{n}} рдЕрдХреНрд╖рд░',
|
||||||
@@ -388,6 +388,7 @@ export default {
|
|||||||
'reacted to your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдкрд░ рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛ рджреА',
|
'reacted to your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдкрд░ рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛ рджреА',
|
||||||
'reposted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд░реАрдкреЛрд╕реНрдЯ рдХрд┐рдпрд╛',
|
'reposted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд░реАрдкреЛрд╕реНрдЯ рдХрд┐рдпрд╛',
|
||||||
'zapped your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
|
'zapped your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
|
||||||
|
'highlighted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдХрд┐рдпрд╛',
|
||||||
'zapped you': 'рдиреЗ рдЖрдкрдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
|
'zapped you': 'рдиреЗ рдЖрдкрдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
|
||||||
'Mark as read': 'рдкрдврд╝рд╛ рд╣реБрдЖ рдорд╛рд░реНрдХ рдХрд░реЗрдВ',
|
'Mark as read': 'рдкрдврд╝рд╛ рд╣реБрдЖ рдорд╛рд░реНрдХ рдХрд░реЗрдВ',
|
||||||
Report: 'рд░рд┐рдкреЛрд░реНрдЯ рдХрд░реЗрдВ',
|
Report: 'рд░рд┐рдкреЛрд░реНрдЯ рдХрд░реЗрдВ',
|
||||||
@@ -493,14 +494,14 @@ export default {
|
|||||||
Remote: 'рд░рд┐рдореЛрдЯ',
|
Remote: 'рд░рд┐рдореЛрдЯ',
|
||||||
'Encrypted Key': 'рдПрдиреНрдХреНрд░рд┐рдкреНрдЯреЗрдб рдХреА',
|
'Encrypted Key': 'рдПрдиреНрдХреНрд░рд┐рдкреНрдЯреЗрдб рдХреА',
|
||||||
'Private Key': 'рдкреНрд░рд╛рдЗрд╡реЗрдЯ рдХреА',
|
'Private Key': 'рдкреНрд░рд╛рдЗрд╡реЗрдЯ рдХреА',
|
||||||
'Welcome to Jumble': 'Jumble рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ',
|
'Welcome to Smesh': 'Smesh рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ',
|
||||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||||
'Jumble рдПрдХ рдХреНрд▓рд╛рдЗрдВрдЯ рд╣реИ рдЬреЛ рд░рд┐рд▓реЗ рдмреНрд░рд╛рдЙрдЬрд╝ рдХрд░рдиреЗ рдкрд░ рдХреЗрдВрджреНрд░рд┐рдд рд╣реИред рд░реЛрдЪрдХ рд░рд┐рд▓реЗ рдХреА рдЦреЛрдЬ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ рдпрд╛ рдЕрдкрдиреА рдлрд╝реЙрд▓реЛрдЗрдВрдЧ рдлрд╝реАрдб рджреЗрдЦрдиреЗ рдХреЗ рд▓рд┐рдП рд▓реЙрдЧрд┐рди рдХрд░реЗрдВред',
|
'Smesh рдПрдХ рдХреНрд▓рд╛рдЗрдВрдЯ рд╣реИ рдЬреЛ рд░рд┐рд▓реЗ рдмреНрд░рд╛рдЙрдЬрд╝ рдХрд░рдиреЗ рдкрд░ рдХреЗрдВрджреНрд░рд┐рдд рд╣реИред рд░реЛрдЪрдХ рд░рд┐рд▓реЗ рдХреА рдЦреЛрдЬ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ рдпрд╛ рдЕрдкрдиреА рдлрд╝реЙрд▓реЛрдЗрдВрдЧ рдлрд╝реАрдб рджреЗрдЦрдиреЗ рдХреЗ рд▓рд┐рдП рд▓реЙрдЧрд┐рди рдХрд░реЗрдВред',
|
||||||
'Explore Relays': 'рд░рд┐рд▓реЗ рдПрдХреНрд╕рдкреНрд▓реЛрд░ рдХрд░реЗрдВ',
|
'Explore Relays': 'рд░рд┐рд▓реЗ рдПрдХреНрд╕рдкреНрд▓реЛрд░ рдХрд░реЗрдВ',
|
||||||
'Choose a feed': 'рдПрдХ рдлреАрдб рдЪреБрдиреЗрдВ',
|
'Choose a feed': 'рдПрдХ рдлреАрдб рдЪреБрдиреЗрдВ',
|
||||||
'and {{x}} others': 'рдФрд░ {{x}} рдЕрдиреНрдп',
|
'and {{x}} others': 'рдФрд░ {{x}} рдЕрдиреНрдп',
|
||||||
selfZapWarning:
|
selfZapWarning:
|
||||||
'Jumble рдЖрдкрдХреЗ рджреНрд╡рд╛рд░рд╛ рд╕реНрд╡рдпрдВ рдХреЛ zap рдХрд░рдиреЗ рдкрд░ рдХреНрдпрд╛ рд╣реЛрддрд╛ рд╣реИ, рдЗрд╕рдХреЗ рд▓рд┐рдП рдЬрд┐рдореНрдореЗрджрд╛рд░ рдирд╣реАрдВ рд╣реИред рдЕрдкрдиреА рдЬреЛрдЦрд┐рдо рдкрд░ рдЖрдЧреЗ рдмрдврд╝реЗрдВред ЁЯШЙтЪб',
|
'Smesh рдЖрдкрдХреЗ рджреНрд╡рд╛рд░рд╛ рд╕реНрд╡рдпрдВ рдХреЛ zap рдХрд░рдиреЗ рдкрд░ рдХреНрдпрд╛ рд╣реЛрддрд╛ рд╣реИ, рдЗрд╕рдХреЗ рд▓рд┐рдП рдЬрд┐рдореНрдореЗрджрд╛рд░ рдирд╣реАрдВ рд╣реИред рдЕрдкрдиреА рдЬреЛрдЦрд┐рдо рдкрд░ рдЖрдЧреЗ рдмрдврд╝реЗрдВред ЁЯШЙтЪб',
|
||||||
'Emoji Pack': 'рдЗрдореЛрдЬреА рдкреИрдХ',
|
'Emoji Pack': 'рдЗрдореЛрдЬреА рдкреИрдХ',
|
||||||
'Emoji pack added': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рд╛ рдЧрдпрд╛',
|
'Emoji pack added': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рд╛ рдЧрдпрд╛',
|
||||||
'Add emoji pack failed': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рдирд╛ рд╡рд┐рдлрд▓ рд░рд╣рд╛',
|
'Add emoji pack failed': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рдирд╛ рд╡рд┐рдлрд▓ рд░рд╣рд╛',
|
||||||
@@ -590,6 +591,60 @@ export default {
|
|||||||
'Special Follow': 'рд╡рд┐рд╢реЗрд╖ рдлрд╝реЙрд▓реЛ',
|
'Special Follow': 'рд╡рд┐рд╢реЗрд╖ рдлрд╝реЙрд▓реЛ',
|
||||||
'Unfollow Special': 'рд╡рд┐рд╢реЗрд╖ рдЕрдирдлрд╝реЙрд▓реЛ',
|
'Unfollow Special': 'рд╡рд┐рд╢реЗрд╖ рдЕрдирдлрд╝реЙрд▓реЛ',
|
||||||
'Personal Feeds': 'рд╡реНрдпрдХреНрддрд┐рдЧрдд рдлрд╝реАрдб',
|
'Personal Feeds': 'рд╡реНрдпрдХреНрддрд┐рдЧрдд рдлрд╝реАрдб',
|
||||||
'Relay Feeds': 'рд░рд┐рд▓реЗ рдлрд╝реАрдб'
|
'Relay Feeds': 'рд░рд┐рд▓реЗ рдлрд╝реАрдб',
|
||||||
|
'Create Highlight': 'рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдмрдирд╛рдПрдВ',
|
||||||
|
'Write your thoughts about this highlight...': 'рдЗрд╕ рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдХреЗ рдмрд╛рд░реЗ рдореЗрдВ рдЕрдкрдиреЗ рд╡рд┐рдЪрд╛рд░ рд▓рд┐рдЦреЗрдВ...',
|
||||||
|
'Publish Highlight': 'рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдкреНрд░рдХрд╛рд╢рд┐рдд рдХрд░реЗрдВ',
|
||||||
|
'Show replies': 'рдЬрд╡рд╛рдм рджрд┐рдЦрд╛рдПрдВ',
|
||||||
|
'Hide replies': 'рдЬрд╡рд╛рдм рдЫреБрдкрд╛рдПрдВ',
|
||||||
|
'Welcome to Smesh!': 'Smesh рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ!',
|
||||||
|
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
|
||||||
|
'рдЖрдкрдХрд╛ рдлрд╝реАрдб рдЦрд╛рд▓реА рд╣реИ рдХреНрдпреЛрдВрдХрд┐ рдЖрдк рдЕрднреА рддрдХ рдХрд┐рд╕реА рдХреЛ рдлрд╝реЙрд▓реЛ рдирд╣реАрдВ рдХрд░ рд░рд╣реЗ рд╣реИрдВред рджрд┐рд▓рдЪрд╕реНрдк рд╕рд╛рдордЧреНрд░реА рдХрд╛ рдЕрдиреНрд╡реЗрд╖рдг рдХрд░рдХреЗ рдФрд░ рдЕрдкрдиреА рдкрд╕рдВрдж рдХреЗ рдЙрдкрдпреЛрдЧрдХрд░реНрддрд╛рдУрдВ рдХреЛ рдлрд╝реЙрд▓реЛ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ!',
|
||||||
|
'Search Users': 'рдЙрдкрдпреЛрдЧрдХрд░реНрддрд╛ рдЦреЛрдЬреЗрдВ',
|
||||||
|
'Create New Account': 'рдирдпрд╛ рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
|
||||||
|
Important: 'рдорд╣рддреНрд╡рдкреВрд░реНрдг',
|
||||||
|
'Generate Your Account': 'рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
|
||||||
|
'Your private key IS your account. Keep it safe!':
|
||||||
|
'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╣реА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдЗрд╕реЗ рд╕реБрд░рдХреНрд╖рд┐рдд рд░рдЦреЗрдВ!',
|
||||||
|
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
|
||||||
|
'Nostr рдореЗрдВ, рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╣реА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдпрджрд┐ рдЖрдк рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдЦреЛ рджреЗрддреЗ рд╣реИрдВ, рддреЛ рдЖрдк рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рд╣рдореЗрд╢рд╛ рдХреЗ рд▓рд┐рдП рдЦреЛ рджреЗрддреЗ рд╣реИрдВред',
|
||||||
|
'Your Private Key': 'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА',
|
||||||
|
'Generate new key': 'рдирдИ рдХреБрдВрдЬреА рдмрдирд╛рдПрдВ',
|
||||||
|
'Download Backup File': 'рдмреИрдХрдЕрдк рдлрд╝рд╛рдЗрд▓ рдбрд╛рдЙрдирд▓реЛрдб рдХрд░реЗрдВ',
|
||||||
|
'Copied to Clipboard': 'рдХреНрд▓рд┐рдкрдмреЛрд░реНрдб рдкрд░ рдХреЙрдкреА рдХрд┐рдпрд╛ рдЧрдпрд╛',
|
||||||
|
'Copy to Clipboard': 'рдХреНрд▓рд┐рдкрдмреЛрд░реНрдб рдкрд░ рдХреЙрдкреА рдХрд░реЗрдВ',
|
||||||
|
'I already saved my private key securely.':
|
||||||
|
'рдореИрдВрдиреЗ рдкрд╣рд▓реЗ рд╣реА рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рд░реВрдк рд╕реЗ рд╕рд╣реЗрдЬ рд▓рд┐рдпрд╛ рд╣реИред',
|
||||||
|
'Almost Done!': 'рд▓рдЧрднрдЧ рд╣реЛ рдЧрдпрд╛!',
|
||||||
|
'Set a password to encrypt your key, or skip to finish':
|
||||||
|
'рдЕрдкрдиреА рдХреБрдВрдЬреА рдХреЛ рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдкрд╛рд╕рд╡рд░реНрдб рд╕реЗрдЯ рдХрд░реЗрдВ, рдпрд╛ рд╕рдорд╛рдкреНрдд рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдЫреЛрдбрд╝реЗрдВ',
|
||||||
|
'Password Protection (Optional)': 'рдкрд╛рд╕рд╡рд░реНрдб рд╕реБрд░рдХреНрд╖рд╛ (рд╡реИрдХрд▓реНрдкрд┐рдХ)',
|
||||||
|
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
|
||||||
|
'рдкрд╛рд╕рд╡рд░реНрдб рд╕реЗрдЯ рдХрд░рдиреЗ рд╕реЗ рдЗрд╕ рдмреНрд░рд╛рдЙрдЬрд╝рд░ рдореЗрдВ рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рд╣реЛ рдЬрд╛рддреА рд╣реИред рдЖрдк рдЗрд╕ рдЪрд░рдг рдХреЛ рдЫреЛрдбрд╝ рд╕рдХрддреЗ рд╣реИрдВ, рд▓реЗрдХрд┐рди рд╣рдо рдЕрддрд┐рд░рд┐рдХреНрдд рд╕реБрд░рдХреНрд╖рд╛ рдХреЗ рд▓рд┐рдП рдПрдХ рд╕реЗрдЯ рдХрд░рдиреЗ рдХреА рд╕рд▓рд╛рд╣ рджреЗрддреЗ рд╣реИрдВред',
|
||||||
|
'Password (Optional)': 'рдкрд╛рд╕рд╡рд░реНрдб (рд╡реИрдХрд▓реНрдкрд┐рдХ)',
|
||||||
|
'Enter password or leave empty to skip': 'рдкрд╛рд╕рд╡рд░реНрдб рджрд░реНрдЬ рдХрд░реЗрдВ рдпрд╛ рдЫреЛрдбрд╝рдиреЗ рдХреЗ рд▓рд┐рдП рдЦрд╛рд▓реА рдЫреЛрдбрд╝реЗрдВ',
|
||||||
|
'Confirm Password': 'рдкрд╛рд╕рд╡рд░реНрдб рдХреА рдкреБрд╖реНрдЯрд┐ рдХрд░реЗрдВ',
|
||||||
|
'Re-enter password': 'рдкрд╛рд╕рд╡рд░реНрдб рдлрд┐рд░ рд╕реЗ рджрд░реНрдЬ рдХрд░реЗрдВ',
|
||||||
|
'Passwords do not match': 'рдкрд╛рд╕рд╡рд░реНрдб рдореЗрд▓ рдирд╣реАрдВ рдЦрд╛рддреЗ',
|
||||||
|
'Finish Signup': 'рд╕рд╛рдЗрдирдЕрдк рд╕рдорд╛рдкреНрдд рдХрд░реЗрдВ',
|
||||||
|
// New improved signup copy
|
||||||
|
'Create Your Nostr Account': 'рдЕрдкрдирд╛ Nostr рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
|
||||||
|
'Generate your unique private key. This is your digital identity.':
|
||||||
|
'рдЕрдкрдиреА рдЕрджреНрд╡рд┐рддреАрдп рдирд┐рдЬреА рдХреБрдВрдЬреА рдЙрддреНрдкрдиреНрди рдХрд░реЗрдВред рдпрд╣ рдЖрдкрдХреА рдбрд┐рдЬрд┐рдЯрд▓ рдкрд╣рдЪрд╛рди рд╣реИред',
|
||||||
|
'Critical: Save Your Private Key': 'рдорд╣рддреНрд╡рдкреВрд░реНрдг: рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╕рд╣реЗрдЬреЗрдВ',
|
||||||
|
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||||
|
'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдХреЛрдИ рдкрд╛рд╕рд╡рд░реНрдб рдкреБрдирд░реНрдкреНрд░рд╛рдкреНрддрд┐ рдирд╣реАрдВ рд╣реИред рдпрджрд┐ рдЖрдк рдЗрд╕реЗ рдЦреЛ рджреЗрддреЗ рд╣реИрдВ, рддреЛ рдЖрдк рд╣рдореЗрд╢рд╛ рдХреЗ рд▓рд┐рдП рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рдЦреЛ рджреЗрдВрдЧреЗред рдХреГрдкрдпрд╛ рдЗрд╕реЗ рд╕реБрд░рдХреНрд╖рд┐рдд рд╕реНрдерд╛рди рдкрд░ рд╕рд╣реЗрдЬреЗрдВред',
|
||||||
|
'I have safely backed up my private key':
|
||||||
|
'рдореИрдВрдиреЗ рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рд░реВрдк рд╕реЗ рдмреИрдХрдЕрдк рдХрд░ рд▓рд┐рдпрд╛ рд╣реИ',
|
||||||
|
'Secure Your Account': 'рдЕрдкрдиреЗ рдЦрд╛рддреЗ рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рдХрд░реЗрдВ',
|
||||||
|
'Add an extra layer of protection with a password':
|
||||||
|
'рдкрд╛рд╕рд╡рд░реНрдб рдХреЗ рд╕рд╛рде рд╕реБрд░рдХреНрд╖рд╛ рдХреА рдПрдХ рдЕрддрд┐рд░рд┐рдХреНрдд рдкрд░рдд рдЬреЛрдбрд╝реЗрдВ',
|
||||||
|
'Password Protection (Recommended)': 'рдкрд╛рд╕рд╡рд░реНрдб рд╕реБрд░рдХреНрд╖рд╛ (рдЕрдиреБрд╢рдВрд╕рд┐рдд)',
|
||||||
|
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||||
|
'рдЗрд╕ рдмреНрд░рд╛рдЙрдЬрд╝рд░ рдореЗрдВ рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдкрд╛рд╕рд╡рд░реНрдб рдЬреЛрдбрд╝реЗрдВред рдпрд╣ рд╡реИрдХрд▓реНрдкрд┐рдХ рд╣реИ рд▓реЗрдХрд┐рди рдмреЗрд╣рддрд░ рд╕реБрд░рдХреНрд╖рд╛ рдХреЗ рд▓рд┐рдП рджреГрдврд╝рддрд╛ рд╕реЗ рдЕрдиреБрд╢рдВрд╕рд┐рдд рд╣реИред',
|
||||||
|
'Create a password (or skip)': 'рдПрдХ рдкрд╛рд╕рд╡рд░реНрдб рдмрдирд╛рдПрдВ (рдпрд╛ рдЫреЛрдбрд╝реЗрдВ)',
|
||||||
|
'Enter your password again': 'рдЕрдкрдирд╛ рдкрд╛рд╕рд╡рд░реНрдб рдлрд┐рд░ рд╕реЗ рджрд░реНрдЬ рдХрд░реЗрдВ',
|
||||||
|
'Complete Signup': 'рд╕рд╛рдЗрдирдЕрдк рдкреВрд░реНрдг рдХрд░реЗрдВ',
|
||||||
|
Recommended: 'рдЕрдиреБрд╢рдВрд╕рд┐рдд'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||