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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
- **UI Framework**: Tailwind CSS + Radix UI
|
||||
- **State Management**: Jotai
|
||||
@@ -37,7 +37,7 @@ Jumble is a user-friendly Nostr client for exploring relay feeds.
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
jumble/
|
||||
smesh/
|
||||
├── src/
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── 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)
|
||||
|
||||
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/`
|
||||
- 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
|
||||
|
||||
|
||||
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">
|
||||
<picture>
|
||||
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" />
|
||||
<img src="./resources/logo-light.svg" alt="Smesh Logo" width="400" />
|
||||
</picture>
|
||||
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
|
||||
</div>
|
||||
|
||||
# Jumble
|
||||
# Smesh
|
||||
|
||||
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
|
||||
|
||||
> Some interesting forks of Jumble.
|
||||
> Some interesting forks of Smesh.
|
||||
|
||||
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://jumble.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
|
||||
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://jumble.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
|
||||
- [https://jumble.imwald.eu/](https://jumble.imwald.eu/) Repo: [Silberengel/jumble](https://github.com/Silberengel/jumble) - by [@Silberengel](https://jumble.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
|
||||
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://smesh.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
|
||||
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://smesh.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
|
||||
- [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
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/CodyTseng/jumble.git
|
||||
git clone https://git.mleku.dev/mleku/smesh.git
|
||||
|
||||
# Go into the repository
|
||||
cd jumble
|
||||
cd smesh
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
@@ -39,10 +39,10 @@ npm run dev
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/CodyTseng/jumble.git
|
||||
git clone https://git.mleku.dev/mleku/smesh.git
|
||||
|
||||
# Go into the repository
|
||||
cd jumble
|
||||
cd smesh
|
||||
|
||||
# Run the docker compose
|
||||
docker compose up --build -d
|
||||
@@ -62,7 +62,7 @@ If you like this project, you can buy me a coffee :)
|
||||
|
||||
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
|
||||
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
|
||||
- **Geyser:** https://geyser.fund/project/jumble
|
||||
- **Geyser:** https://geyser.fund/project/smesh
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
services:
|
||||
jumble:
|
||||
container_name: jumble-nginx
|
||||
smesh:
|
||||
container_name: smesh-nginx
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
ports:
|
||||
- '8089:80'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- jumble
|
||||
- smesh
|
||||
|
||||
proxy-server:
|
||||
image: ghcr.io/danvergara/jumble-proxy-server:latest
|
||||
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||
environment:
|
||||
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
|
||||
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
|
||||
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||
- ENABLE_PPROF=true
|
||||
- PORT=8080
|
||||
ports:
|
||||
- '8090:8080'
|
||||
networks:
|
||||
- jumble
|
||||
- smesh
|
||||
|
||||
nostr-relay:
|
||||
image: scsibug/nostr-rs-relay:latest
|
||||
container_name: jumble-nostr-relay
|
||||
container_name: smesh-nostr-relay
|
||||
ports:
|
||||
- '7000:8080'
|
||||
environment:
|
||||
@@ -34,11 +34,11 @@ services:
|
||||
volumes:
|
||||
- relay-data:/usr/src/app/db
|
||||
networks:
|
||||
- jumble
|
||||
- smesh
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
relay-data:
|
||||
|
||||
networks:
|
||||
jumble:
|
||||
smesh:
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
jumble:
|
||||
container_name: jumble-nginx
|
||||
smesh:
|
||||
container_name: smesh-nginx
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
VITE_PROXY_SERVER: ${SMESH_PROXY_SERVER_URL:-http://localhost:8090}
|
||||
ports:
|
||||
- '8089:80'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- jumble
|
||||
- smesh
|
||||
|
||||
proxy-server:
|
||||
image: ghcr.io/danvergara/jumble-proxy-server:latest
|
||||
image: ghcr.io/danvergara/smesh-proxy-server:latest
|
||||
environment:
|
||||
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
|
||||
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
|
||||
- ALLOW_ORIGIN=${SMESH_SOCIAL_URL:-http://localhost:8089}
|
||||
- SMESH_PROXY_GITHUB_TOKEN=${SMESH_PROXY_GITHUB_TOKEN}
|
||||
- ENABLE_PPROF=true
|
||||
- PORT=8080
|
||||
ports:
|
||||
- '8090:8080'
|
||||
networks:
|
||||
- jumble
|
||||
- smesh
|
||||
|
||||
networks:
|
||||
jumble:
|
||||
smesh:
|
||||
|
||||
37
index.html
@@ -4,30 +4,53 @@
|
||||
<meta charset="UTF-8" />
|
||||
<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="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.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="#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:title" content="Jumble" />
|
||||
<meta property="og:title" content="Smesh" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A user-friendly Nostr client for exploring relay feeds"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true"
|
||||
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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1239
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "jumble",
|
||||
"name": "smesh",
|
||||
"version": "0.1.0",
|
||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||
"private": true,
|
||||
@@ -8,11 +8,12 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"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": {
|
||||
"dev": "vite --host",
|
||||
"dev:8080": "vite --host 0.0.0.0 --port 8080",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@getalby/bitcoin-connect-react": "^3.10.0",
|
||||
"@noble/hashes": "^1.6.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@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 { PinListProvider } from '@/providers/PinListProvider'
|
||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||
import { ReplyProvider } from '@/providers/ReplyProvider'
|
||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
||||
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
|
||||
import { UserTrustProvider } from '@/providers/UserTrustProvider'
|
||||
import { ZapProvider } from '@/providers/ZapProvider'
|
||||
@@ -33,8 +31,7 @@ export default function App(): JSX.Element {
|
||||
<DeletedEventProvider>
|
||||
<NostrProvider>
|
||||
<ZapProvider>
|
||||
<TranslationServiceProvider>
|
||||
<FavoriteRelaysProvider>
|
||||
<FavoriteRelaysProvider>
|
||||
<FollowListProvider>
|
||||
<MuteListProvider>
|
||||
<UserTrustProvider>
|
||||
@@ -43,14 +40,12 @@ export default function App(): JSX.Element {
|
||||
<PinListProvider>
|
||||
<PinnedUsersProvider>
|
||||
<FeedProvider>
|
||||
<ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</FeedProvider>
|
||||
</PinnedUsersProvider>
|
||||
</PinListProvider>
|
||||
@@ -59,8 +54,7 @@ export default function App(): JSX.Element {
|
||||
</UserTrustProvider>
|
||||
</MuteListProvider>
|
||||
</FollowListProvider>
|
||||
</FavoriteRelaysProvider>
|
||||
</TranslationServiceProvider>
|
||||
</FavoriteRelaysProvider>
|
||||
</ZapProvider>
|
||||
</NostrProvider>
|
||||
</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 }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1080 1228"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
xmlSpace="preserve"
|
||||
style={{
|
||||
fill: 'currentcolor',
|
||||
fillRule: 'evenodd',
|
||||
clipRule: 'evenodd',
|
||||
strokeLinejoin: 'round',
|
||||
strokeMiterlimit: 2
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
id="Icon-Curve-Cut"
|
||||
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
const { theme } = useTheme()
|
||||
const iconSrc = theme === 'light' ? iconLight : iconDark
|
||||
|
||||
return <img src={iconSrc} alt="Smesh" className={className} />
|
||||
}
|
||||
|
||||
|
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 = (
|
||||
<>
|
||||
<div className="text-xl font-semibold">Jumble</div>
|
||||
<div className="text-xl font-semibold">Smesh</div>
|
||||
<div className="text-muted-foreground">
|
||||
A user-friendly Nostr client for exploring relay feeds
|
||||
</div>
|
||||
@@ -21,15 +21,15 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
||||
<div>
|
||||
Source code:{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble"
|
||||
href="https://git.mleku.dev/mleku/smesh"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
Git
|
||||
</a>
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -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 { isDevEnv } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { NstartModal } from 'nstart-modal'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
import NostrConnectLogin from './NostrConnectionLogin'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
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 }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'generate' ? (
|
||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'npub' ? (
|
||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'signup' ? (
|
||||
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
@@ -41,9 +39,8 @@ function AccountManagerNav({
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { themeSetting } = useTheme()
|
||||
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t("Don't have an account yet?")}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const wizard = new NstartModal({
|
||||
baseUrl: 'https://nstart.me',
|
||||
an: 'Jumble',
|
||||
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
|
||||
al: i18n.language.slice(0, 2),
|
||||
onComplete: ({ nostrLogin }) => {
|
||||
if (!nostrLogin) return
|
||||
|
||||
if (nostrLogin.startsWith('bunker://')) {
|
||||
bunkerLogin(nostrLogin)
|
||||
} else if (nostrLogin.startsWith('ncryptsec')) {
|
||||
ncryptsecLogin(nostrLogin)
|
||||
} else if (nostrLogin.startsWith('nsec')) {
|
||||
nsecLogin(nostrLogin)
|
||||
}
|
||||
}
|
||||
})
|
||||
close?.()
|
||||
wizard.open()
|
||||
}}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setPage('generate')}
|
||||
className="w-full text-muted-foreground py-0 h-fit mt-1"
|
||||
>
|
||||
{t('or simply generate a private key')}
|
||||
<Button onClick={() => setPage('signup')} className="w-full mt-4">
|
||||
{t('Create New Account')}
|
||||
</Button>
|
||||
</div>
|
||||
{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 {
|
||||
EmbeddedEmojiParser,
|
||||
EmbeddedEventParser,
|
||||
@@ -15,7 +14,7 @@ import { cn } from '@/lib/utils'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { TImetaInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
EmbeddedHashtag,
|
||||
EmbeddedLNInvoice,
|
||||
@@ -25,8 +24,10 @@ import {
|
||||
} from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
import HighlightButton from '../HighlightButton'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import PostEditor from '../PostEditor'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
@@ -35,16 +36,20 @@ export default function Content({
|
||||
event,
|
||||
content,
|
||||
className,
|
||||
mustLoadMedia
|
||||
mustLoadMedia,
|
||||
enableHighlight = false
|
||||
}: {
|
||||
event?: Event
|
||||
content?: string
|
||||
className?: string
|
||||
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 _content = translatedEvent?.content ?? event?.content ?? content
|
||||
const _content = event?.content ?? content
|
||||
if (!_content) return {}
|
||||
|
||||
const nodes = parseContent(_content, [
|
||||
@@ -89,87 +94,105 @@ export default function Content({
|
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||
|
||||
return { nodes, allImages, emojiInfos, lastNormalUrl }
|
||||
}, [event, translatedEvent, content])
|
||||
}, [event, content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleHighlight = (text: string) => {
|
||||
setSelectedText(text)
|
||||
setShowHighlightEditor(true)
|
||||
}
|
||||
|
||||
let imageIndex = 0
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const start = imageIndex
|
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||
imageIndex = end
|
||||
return (
|
||||
<ImageGallery
|
||||
className="mt-2"
|
||||
key={index}
|
||||
images={allImages}
|
||||
start={start}
|
||||
end={end}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return (
|
||||
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
|
||||
)
|
||||
}
|
||||
if (node.type === 'url') {
|
||||
return <ExternalLink url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
const id = node.data.split(':')[1]
|
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return (
|
||||
<YoutubeEmbeddedPlayer
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
</div>
|
||||
<>
|
||||
<div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const start = imageIndex
|
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||
imageIndex = end
|
||||
return (
|
||||
<ImageGallery
|
||||
className="mt-2"
|
||||
key={index}
|
||||
images={allImages}
|
||||
start={start}
|
||||
end={end}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return (
|
||||
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
|
||||
)
|
||||
}
|
||||
if (node.type === 'url') {
|
||||
return <ExternalLink url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
const id = node.data.split(':')[1]
|
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return (
|
||||
<YoutubeEmbeddedPlayer
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
</div>
|
||||
{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 { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -14,17 +13,12 @@ export default function HighlightPreview({
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Highlight')}]{' '}
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
emojiInfos={emojiInfos}
|
||||
className="italic pr-0.5"
|
||||
/>
|
||||
<Content content={event.content} emojiInfos={emojiInfos} className="italic pr-0.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
@@ -11,14 +10,7 @@ export default function NormalContentPreview({
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
|
||||
|
||||
return (
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
className={className}
|
||||
emojiInfos={emojiInfos}
|
||||
/>
|
||||
)
|
||||
return <Content content={event.content} className={className} emojiInfos={emojiInfos} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -8,17 +7,12 @@ import Content from './Content'
|
||||
|
||||
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
|
||||
|
||||
return (
|
||||
<div className={cn('pointer-events-none', className)}>
|
||||
[{t('Poll')}]{' '}
|
||||
<Content
|
||||
content={translatedEvent?.content ?? event.content}
|
||||
emojiInfos={emojiInfos}
|
||||
className="italic pr-0.5"
|
||||
/>
|
||||
<Content content={event.content} emojiInfos={emojiInfos} className="italic pr-0.5" />
|
||||
</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 { JUMBLE_PUBKEY } from '@/constants'
|
||||
import { SMESH_PUBKEY } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
import PlatinumSponsors from './PlatinumSponsors'
|
||||
import RecentSupporters from './RecentSupporters'
|
||||
|
||||
export default function Donation({ className }: { className?: string }) {
|
||||
@@ -14,9 +13,9 @@ export default function Donation({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
|
||||
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
|
||||
<div className="text-center font-semibold">{t('Enjoying Smesh?')}</div>
|
||||
<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 className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
@@ -40,12 +39,11 @@ export default function Donation({ className }: { className?: string }) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PlatinumSponsors />
|
||||
<RecentSupporters />
|
||||
<ZapDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
pubkey={JUMBLE_PUBKEY}
|
||||
pubkey={SMESH_PUBKEY}
|
||||
defaultAmount={donationAmount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="344.564 330.278 111.737 91.218" width="53.87" height="43.61" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><radialGradient xlink:href="#logo_svg__a" id="logo_svg__b" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><radialGradient xlink:href="#logo_svg__a" id="logo_svg__c" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><linearGradient id="logo_svg__a"><stop style="stop-color:#ffb200;stop-opacity:1" offset="0"></stop><stop style="stop-color:#ff6b01;stop-opacity:1" offset="0.493"></stop></linearGradient></defs><path style="font-variation-settings:'wght' 700;opacity:1;fill:url(#logo_svg__b);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M32.574 39.319v3.81h16.11v-3.81z" transform="translate(324.22 304.883) scale(2.39915)"></path><path style="font-variation-settings:'wght' 700;fill:url(#logo_svg__c);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M14.85 16.062v4.551l8.944 5.681v.137l-8.945 5.68v4.551l13.029-8.555v-3.49Z" transform="translate(324.22 304.883) scale(2.39915)"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -33,16 +33,16 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
<p className="text-lg text-center max-w-md">
|
||||
Sorry for the inconvenience. If you don't mind helping, you can{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble/issues/new"
|
||||
href="https://git.mleku.dev/mleku/smesh/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
submit an issue on GitHub
|
||||
submit an issue
|
||||
</a>{' '}
|
||||
with the error details, or{' '}
|
||||
<a
|
||||
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
|
||||
href="https://smesh.mleku.dev/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { recommendRelaysByLanguage } from '@/lib/relay'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
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 { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function Explore() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
|
||||
const recommendedRelays = useMemo(() => {
|
||||
const lang = i18n.language
|
||||
const relays = recommendRelaysByLanguage(lang)
|
||||
return relays
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
relayInfoService.getAwesomeRelayCollections().then(setCollections)
|
||||
}, [])
|
||||
|
||||
if (!collections) {
|
||||
if (!collections && recommendedRelays.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 max-md:border-b">
|
||||
@@ -31,9 +39,19 @@ export default function Explore() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{collections.map((collection) => (
|
||||
<RelayCollection key={collection.id} collection={collection} />
|
||||
))}
|
||||
{recommendedRelays.length > 0 && (
|
||||
<RelayCollection
|
||||
collection={{
|
||||
id: 'recommended',
|
||||
name: t('Recommended'),
|
||||
relays: recommendedRelays
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{collections &&
|
||||
collections.map((collection) => (
|
||||
<RelayCollection key={collection.id} collection={collection} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function ExternalContentInteractions({
|
||||
pageIndex,
|
||||
externalContent
|
||||
}: {
|
||||
pageIndex?: number
|
||||
externalContent: string
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
||||
list = <ReplyNoteList stuff={externalContent} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList stuff={externalContent} />
|
||||
|
||||
@@ -155,7 +155,7 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||
|
||||
const copyShareLink = () => {
|
||||
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 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="rounded-full min-w-28"
|
||||
variant={hover ? 'destructive' : 'secondary'}
|
||||
disabled={updating}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{updating ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : hover ? (
|
||||
t('Unfollow')
|
||||
) : (
|
||||
t('buttonFollowing')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('Are you sure you want to unfollow this user?')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
|
||||
{t('Unfollow')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="rounded-full min-w-28"
|
||||
variant={hover ? 'destructive' : 'secondary'}
|
||||
disabled={updating}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{updating ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : hover ? (
|
||||
t('Unfollow')
|
||||
) : (
|
||||
t('buttonFollowing')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('Are you sure you want to unfollow this user?')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleUnfollow} variant="destructive">
|
||||
{t('Unfollow')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
|
||||
{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 (
|
||||
<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 */}
|
||||
{isLoading && dim?.width && dim?.height && (
|
||||
<img
|
||||
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(
|
||||
'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
|
||||
)}
|
||||
alt=""
|
||||
@@ -91,7 +91,7 @@ export default function Image({
|
||||
<ThumbHashPlaceholder
|
||||
thumbHash={thumbHash}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
@@ -99,14 +99,14 @@ export default function Image({
|
||||
<BlurHashCanvas
|
||||
blurHash={blurHash}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0',
|
||||
classNames.skeleton
|
||||
)}
|
||||
@@ -124,7 +124,7 @@ export default function Image({
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
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' : '',
|
||||
className
|
||||
)}
|
||||
@@ -137,7 +137,7 @@ export default function Image({
|
||||
alt={alt}
|
||||
decoding="async"
|
||||
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
|
||||
|
||||
@@ -94,9 +94,9 @@ export default function ImageGallery({
|
||||
<ImageWithLightbox
|
||||
key={i}
|
||||
image={image}
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||
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 = (
|
||||
<Image
|
||||
key={0}
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||
classNames={{
|
||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||
wrapper: 'cursor-zoom-in'
|
||||
wrapper: 'cursor-zoom-in border'
|
||||
}}
|
||||
image={displayImages[0]}
|
||||
onClick={(e) => handlePhotoClick(e, 0)}
|
||||
@@ -122,8 +122,8 @@ export default function ImageGallery({
|
||||
{displayImages.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="aspect-square w-full border"
|
||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
||||
className="aspect-square w-full"
|
||||
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
@@ -136,8 +136,8 @@ export default function ImageGallery({
|
||||
{displayImages.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="aspect-square w-full border"
|
||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
||||
className="aspect-square w-full"
|
||||
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function ImageWithLightbox({
|
||||
key={0}
|
||||
className={className}
|
||||
classNames={{
|
||||
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
|
||||
wrapper: cn('border cursor-zoom-in', classNames.wrapper),
|
||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||
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 { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertCard from '../AlertCard'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertCard
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
|
||||
content={
|
||||
showReadWarning
|
||||
|
||||
@@ -15,13 +15,15 @@ export default function NormalFeed({
|
||||
areAlgoRelays = false,
|
||||
isMainFeed = false,
|
||||
showRelayCloseReason = false,
|
||||
disable24hMode = false
|
||||
disable24hMode = false,
|
||||
onRefresh
|
||||
}: {
|
||||
subRequests: TFeedSubRequest[]
|
||||
areAlgoRelays?: boolean
|
||||
isMainFeed?: boolean
|
||||
showRelayCloseReason?: boolean
|
||||
disable24hMode?: boolean
|
||||
onRefresh?: () => void
|
||||
}) {
|
||||
const { hideUntrustedNotes } = useUserTrust()
|
||||
const { showKinds } = useKindFilter()
|
||||
@@ -65,6 +67,10 @@ export default function NormalFeed({
|
||||
{!supportTouch && (
|
||||
<RefreshButton
|
||||
onClick={() => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
return
|
||||
}
|
||||
if (listMode === '24h') {
|
||||
userAggregationListRef.current?.refresh()
|
||||
} else {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function FollowPack({ event, className }: { event: Event; classNa
|
||||
{image && (
|
||||
<Image
|
||||
image={{ url: image, pubkey: event.pubkey }}
|
||||
className="w-24 h-20 object-cover rounded-lg"
|
||||
className="w-24 h-20 object-cover"
|
||||
classNames={{
|
||||
wrapper: 'w-24 h-20 flex-shrink-0',
|
||||
errorPlaceholder: 'w-24 h-20'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { createFakeEvent } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { isValidPubkey } from '@/lib/pubkey'
|
||||
@@ -14,19 +14,23 @@ import ExternalLink from '../ExternalLink'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
||||
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const comment = useMemo(
|
||||
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1],
|
||||
[event, translatedEvent]
|
||||
() => event.tags.find((tag) => tag[0] === 'comment')?.[1],
|
||||
[event]
|
||||
)
|
||||
|
||||
return (
|
||||
<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="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
||||
<div className="italic whitespace-pre-line">
|
||||
{translatedEvent?.content ?? event.content}
|
||||
<div
|
||||
className="italic whitespace-pre-line"
|
||||
style={{
|
||||
overflowWrap: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
</div>
|
||||
</div>
|
||||
<HighlightSource event={event} />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import ImageWithLightbox from '@/components/ImageWithLightbox'
|
||||
import HighlightButton from '@/components/HighlightButton'
|
||||
import PostEditor from '@/components/PostEditor'
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { toNote, toNoteList, toProfile } from '@/lib/link'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import NostrNode from './NostrNode'
|
||||
@@ -20,6 +22,14 @@ export default function LongFormArticle({
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
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(
|
||||
() =>
|
||||
@@ -74,54 +84,64 @@ export default function LongFormArticle({
|
||||
/>
|
||||
)
|
||||
}) as Components,
|
||||
[]
|
||||
[event.pubkey]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
|
||||
>
|
||||
<h1 className="break-words">{metadata.title}</h1>
|
||||
{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}
|
||||
<>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
|
||||
>
|
||||
{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>
|
||||
<h1 className="break-words">{metadata.title}</h1>
|
||||
{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}
|
||||
</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 && (
|
||||
<Image
|
||||
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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { POLL_TYPE } from '@/constants'
|
||||
import { useTranslatedEvent } from '@/hooks'
|
||||
import { useFetchPollResults } from '@/hooks/useFetchPollResults'
|
||||
import { createPollResponseDraftEvent } from '@/lib/draft-event'
|
||||
import { getPollMetadataFromEvent } from '@/lib/event-metadata'
|
||||
@@ -17,16 +16,12 @@ import { toast } from 'sonner'
|
||||
|
||||
export default function Poll({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const { pubkey, publish, startLogin } = useNostr()
|
||||
const [isVoting, setIsVoting] = useState(false)
|
||||
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
|
||||
const pollResults = useFetchPollResults(event.id)
|
||||
const [isLoadingResults, setIsLoadingResults] = useState(false)
|
||||
const poll = useMemo(
|
||||
() => getPollMetadataFromEvent(translatedEvent ?? event),
|
||||
[event, translatedEvent]
|
||||
)
|
||||
const poll = useMemo(() => getPollMetadataFromEvent(event), [event])
|
||||
const votedOptionIds = useMemo(() => {
|
||||
if (!pollResults || !pubkey) return []
|
||||
return Object.entries(pollResults.results)
|
||||
|
||||
@@ -15,7 +15,6 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import TrustScoreBadge from '../TrustScoreBadge'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
@@ -117,7 +116,7 @@ export default function Note({
|
||||
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
|
||||
content = <FollowPack className="mt-2" event={event} />
|
||||
} else {
|
||||
content = <Content className="mt-2" event={event} />
|
||||
content = <Content className="mt-2" event={event} enableHighlight />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -146,12 +145,9 @@ export default function Note({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} />
|
||||
{size === 'normal' && (
|
||||
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
|
||||
)}
|
||||
</div>
|
||||
{size === 'normal' && (
|
||||
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
|
||||
)}
|
||||
</div>
|
||||
{!hideParentNotePreview && (
|
||||
<ParentNotePreview
|
||||
|
||||
@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
|
||||
import ZapList from '../ZapList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function NoteInteractions({
|
||||
pageIndex,
|
||||
event
|
||||
}: {
|
||||
pageIndex?: number
|
||||
event: Event
|
||||
}) {
|
||||
export default function NoteInteractions({ event }: { event: Event }) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
||||
list = <ReplyNoteList stuff={event} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList stuff={event} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NewNotesButton from '@/components/NewNotesButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
@@ -7,9 +8,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TFeedSubRequest } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -76,11 +77,9 @@ const NoteList = forwardRef<
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { addReplies } = useReply()
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initialLoading, setInitialLoading] = useState(false)
|
||||
const [filtering, setFiltering] = useState(false)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [filteredNotes, setFilteredNotes] = useState<
|
||||
@@ -88,9 +87,7 @@ const NoteList = forwardRef<
|
||||
>([])
|
||||
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const topRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const shouldHideEvent = useCallback(
|
||||
@@ -218,10 +215,6 @@ const NoteList = forwardRef<
|
||||
processEvents().finally(() => setFiltering(false))
|
||||
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
|
||||
|
||||
const slicedNotes = useMemo(() => {
|
||||
return filteredNotes.slice(0, showCount)
|
||||
}, [filteredNotes, showCount])
|
||||
|
||||
useEffect(() => {
|
||||
const processNewEvents = async () => {
|
||||
const keySet = new Set<string>()
|
||||
@@ -273,14 +266,11 @@ const NoteList = forwardRef<
|
||||
if (!subRequests.length) return
|
||||
|
||||
async function init() {
|
||||
setLoading(true)
|
||||
setInitialLoading(true)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
|
||||
setLoading(false)
|
||||
setHasMore(false)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -305,12 +295,9 @@ const NoteList = forwardRef<
|
||||
if (events.length > 0) {
|
||||
setEvents(events)
|
||||
}
|
||||
if (areAlgoRelays) {
|
||||
setHasMore(false)
|
||||
}
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
@@ -323,7 +310,7 @@ const NoteList = forwardRef<
|
||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||
)
|
||||
}
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
},
|
||||
onClose: (url, reason) => {
|
||||
if (!showRelayCloseReason) return
|
||||
@@ -358,55 +345,26 @@ const NoteList = forwardRef<
|
||||
}
|
||||
}, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!timelineKey || areAlgoRelays) return false
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
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 () => {
|
||||
if (showCount < events.length) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
// preload more
|
||||
if (events.length - showCount > LIMIT / 2) {
|
||||
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 { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||
items: filteredNotes,
|
||||
showCount: SHOW_COUNT,
|
||||
onLoadMore: handleLoadMore,
|
||||
initialLoading
|
||||
})
|
||||
|
||||
const showNewEvents = () => {
|
||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||
@@ -419,7 +377,7 @@ const NoteList = forwardRef<
|
||||
const list = (
|
||||
<div className="min-h-screen">
|
||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||
{slicedNotes.map(({ key, event, reposters }) => (
|
||||
{visibleItems.map(({ key, event, reposters }) => (
|
||||
<NoteCard
|
||||
key={key}
|
||||
className="w-full"
|
||||
@@ -428,10 +386,9 @@ const NoteList = forwardRef<
|
||||
reposters={reposters}
|
||||
/>
|
||||
))}
|
||||
{hasMore || showCount < events.length || loading || filtering ? (
|
||||
<div ref={bottomRef}>
|
||||
<NoteCardLoadingSkeleton />
|
||||
</div>
|
||||
<div ref={bottomRef} />
|
||||
{shouldShowLoadingIndicator || filtering || initialLoading ? (
|
||||
<NoteCardLoadingSkeleton />
|
||||
) : events.length ? (
|
||||
<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={{ url: emojiUrl, pubkey: notification.pubkey }}
|
||||
alt={emojiName}
|
||||
className="w-6 h-6 rounded-md"
|
||||
classNames={{ errorPlaceholder: 'bg-transparent' }}
|
||||
className="w-6 h-6"
|
||||
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
|
||||
errorPlaceholder={<Heart size={24} className="text-red-400" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { HighlightNotification } from './HighlightNotification'
|
||||
import { MentionNotification } from './MentionNotification'
|
||||
import { PollResponseNotification } from './PollResponseNotification'
|
||||
import { ReactionNotification } from './ReactionNotification'
|
||||
@@ -60,5 +61,8 @@ export function NotificationItem({
|
||||
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
|
||||
return <PollResponseNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
if (notification.kind === kinds.Highlights) {
|
||||
return <HighlightNotification notification={notification} isNew={isNew} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import client from '@/services/client.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
const { pubkey } = useNostr()
|
||||
const { getNotificationsSeenAt } = useNotification()
|
||||
const { notificationListStyle } = useUserPreferences()
|
||||
const { addReplies } = useReply()
|
||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||
const [lastReadTime, setLastReadTime] = useState(0)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
@@ -55,6 +54,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
case 'mentions':
|
||||
return [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.VOICE_COMMENT,
|
||||
ExtendedKind.POLL
|
||||
@@ -70,6 +70,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
kinds.GenericRepost,
|
||||
kinds.Reaction,
|
||||
kinds.Zap,
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.POLL_RESPONSE,
|
||||
ExtendedKind.VOICE_COMMENT,
|
||||
@@ -141,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
stuffStatsService.updateStuffStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
handleNewEvent(event)
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
|
||||
import { Eraser, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlertCard from '../AlertCard'
|
||||
import InfoCard from '../InfoCard'
|
||||
|
||||
export default function PollEditor({
|
||||
pollCreateData,
|
||||
@@ -125,7 +125,8 @@ export default function PollEditor({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<AlertCard
|
||||
<InfoCard
|
||||
variant="alert"
|
||||
title={t('This is a poll note.')}
|
||||
content={t(
|
||||
'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 { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import {
|
||||
createCommentDraftEvent,
|
||||
createHighlightDraftEvent,
|
||||
createPollDraftEvent,
|
||||
createShortTextNoteDraftEvent,
|
||||
deleteDraftEventCache
|
||||
} from '@/lib/draft-event'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import postEditorCache from '@/services/post-editor-cache.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TPollCreateData } from '@/types'
|
||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -24,22 +26,22 @@ import PostOptions from './PostOptions'
|
||||
import PostRelaySelector from './PostRelaySelector'
|
||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||
import Uploader from './Uploader'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
|
||||
export default function PostContent({
|
||||
defaultContent = '',
|
||||
parentStuff,
|
||||
close,
|
||||
openFrom
|
||||
openFrom,
|
||||
highlightedText
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentStuff?: Event | string
|
||||
close: () => void
|
||||
openFrom?: string[]
|
||||
highlightedText?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { addReplies } = useReply()
|
||||
const [text, setText] = useState('')
|
||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||
const [posting, setPosting] = useState(false)
|
||||
@@ -68,7 +70,7 @@ export default function PostContent({
|
||||
const canPost = useMemo(() => {
|
||||
return (
|
||||
!!pubkey &&
|
||||
!!text &&
|
||||
(!!text || !!highlightedText) &&
|
||||
!posting &&
|
||||
!uploadProgresses.length &&
|
||||
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
|
||||
@@ -77,6 +79,7 @@ export default function PostContent({
|
||||
}, [
|
||||
pubkey,
|
||||
text,
|
||||
highlightedText,
|
||||
posting,
|
||||
uploadProgresses,
|
||||
isPoll,
|
||||
@@ -123,30 +126,23 @@ export default function PostContent({
|
||||
const post = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canPost || postingRef.current) return
|
||||
if (!canPost || !pubkey || postingRef.current) return
|
||||
|
||||
postingRef.current = true
|
||||
setPosting(true)
|
||||
try {
|
||||
const draftEvent =
|
||||
parentStuff &&
|
||||
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
|
||||
? await createCommentDraftEvent(text, parentStuff, mentions, {
|
||||
addClientTag,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
})
|
||||
: isPoll
|
||||
? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, {
|
||||
addClientTag,
|
||||
isNsfw
|
||||
})
|
||||
: await createShortTextNoteDraftEvent(text, mentions, {
|
||||
parentEvent,
|
||||
addClientTag,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
})
|
||||
const draftEvent = await createDraftEvent({
|
||||
parentStuff,
|
||||
highlightedText,
|
||||
text,
|
||||
mentions,
|
||||
isPoll,
|
||||
pollCreateData,
|
||||
pubkey,
|
||||
addClientTag,
|
||||
isProtectedEvent,
|
||||
isNsfw
|
||||
})
|
||||
|
||||
const _additionalRelayUrls = [...additionalRelayUrls]
|
||||
if (parentStuff && typeof parentStuff === 'string') {
|
||||
@@ -160,7 +156,7 @@ export default function PostContent({
|
||||
})
|
||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||
deleteDraftEventCache(draftEvent)
|
||||
addReplies([newEvent])
|
||||
threadService.addRepliesToThread([newEvent])
|
||||
toast.success(t('Post successful'), { duration: 2000 })
|
||||
close()
|
||||
} catch (error) {
|
||||
@@ -205,7 +201,14 @@ export default function PostContent({
|
||||
{parentEvent && (
|
||||
<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">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -220,6 +223,7 @@ export default function PostContent({
|
||||
onUploadStart={handleUploadStart}
|
||||
onUploadProgress={handleUploadProgress}
|
||||
onUploadEnd={handleUploadEnd}
|
||||
placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined}
|
||||
/>
|
||||
{isPoll && (
|
||||
<PollEditor
|
||||
@@ -332,7 +336,7 @@ export default function PostContent({
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentStuff ? t('Reply') : t('Post')}
|
||||
{parentStuff ? (highlightedText ? t('Publish Highlight') : t('Reply')) : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -366,3 +370,62 @@ export default function PostContent({
|
||||
</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 className="text-muted-foreground text-xs">
|
||||
{t('Show others this was sent via Jumble')}
|
||||
{t('Show others this was sent via Smesh')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const PostTextarea = forwardRef<
|
||||
onUploadStart?: (file: File, cancel: () => void) => void
|
||||
onUploadProgress?: (file: File, progress: number) => void
|
||||
onUploadEnd?: (file: File) => void
|
||||
placeholder?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -52,7 +53,8 @@ const PostTextarea = forwardRef<
|
||||
className,
|
||||
onUploadStart,
|
||||
onUploadProgress,
|
||||
onUploadEnd
|
||||
onUploadEnd,
|
||||
placeholder
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -67,6 +69,7 @@ const PostTextarea = forwardRef<
|
||||
HardBreak,
|
||||
Placeholder.configure({
|
||||
placeholder:
|
||||
placeholder ??
|
||||
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||
}),
|
||||
Emoji.configure({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import postEditor from '@/services/post-editor.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Dispatch, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostContent from './PostContent'
|
||||
import Title from './Title'
|
||||
|
||||
@@ -25,14 +26,17 @@ export default function PostEditor({
|
||||
parentStuff,
|
||||
open,
|
||||
setOpen,
|
||||
openFrom
|
||||
openFrom,
|
||||
highlightedText
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentStuff?: Event | string
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
openFrom?: string[]
|
||||
highlightedText?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
const content = useMemo(() => {
|
||||
@@ -42,9 +46,10 @@ export default function PostEditor({
|
||||
parentStuff={parentStuff}
|
||||
close={() => setOpen(false)}
|
||||
openFrom={openFrom}
|
||||
highlightedText={highlightedText}
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
}, [highlightedText])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
@@ -64,7 +69,7 @@ export default function PostEditor({
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-start">
|
||||
<Title parentStuff={parentStuff} />
|
||||
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="hidden" />
|
||||
</SheetHeader>
|
||||
@@ -92,7 +97,7 @@ export default function PostEditor({
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Title parentStuff={parentStuff} />
|
||||
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
|
||||
@@ -6,12 +6,8 @@ import {
|
||||
EmbeddedWebsocketUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { detectLanguage } from '@/lib/utils'
|
||||
import { useTranslationService } from '@/providers/TranslationServiceProvider'
|
||||
import { TEmoji } from '@/types'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo } from 'react'
|
||||
import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
@@ -25,20 +21,10 @@ export default function ProfileAbout({
|
||||
emojis?: TEmoji[]
|
||||
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(() => {
|
||||
if (!about) return null
|
||||
|
||||
const nodes = parseContent(translatedAbout ?? about, [
|
||||
const nodes = parseContent(about, [
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedUrlParser,
|
||||
@@ -73,60 +59,7 @@ export default function ProfileAbout({
|
||||
}
|
||||
return node.data
|
||||
})
|
||||
}, [about, translatedAbout, emojis])
|
||||
}, [about, emojis])
|
||||
|
||||
const handleTranslate = async () => {
|
||||
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>
|
||||
)
|
||||
return <div className={className}>{aboutNodes}</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
@@ -27,7 +26,10 @@ export default function ProfileBanner({
|
||||
<Image
|
||||
image={{ url: bannerUrl, pubkey }}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('rounded-none', className)}
|
||||
className={className}
|
||||
classNames={{
|
||||
wrapper: 'rounded-none'
|
||||
}}
|
||||
errorPlaceholder={defaultBanner}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import QRCodeStyling from 'qr-code-styling'
|
||||
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 }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -13,7 +13,7 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu
|
||||
qrOptions: {
|
||||
errorCorrectionLevel: 'M'
|
||||
},
|
||||
image: iconSvg,
|
||||
image: iconImg,
|
||||
width: size * pixelRatio,
|
||||
height: size * pixelRatio,
|
||||
data: value,
|
||||
|
||||
@@ -9,7 +9,6 @@ import ContentPreview from '../ContentPreview'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import Stars from '../Stars'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
@@ -46,9 +45,6 @@ export default function RelayReviewCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<TranslateButton event={event} className="pr-0" />
|
||||
</div>
|
||||
</div>
|
||||
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
|
||||
<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="space-y-2">
|
||||
<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" />
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ function RelayControls({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
const handleCopyShareableUrl = () => {
|
||||
navigator.clipboard.writeText(`https://jumble.social/?r=${url}`)
|
||||
navigator.clipboard.writeText(`https://smesh.mleku.dev/?r=${url}`)
|
||||
setCopiedShareableUrl(true)
|
||||
toast.success('Shareable URL copied to clipboard')
|
||||
setTimeout(() => setCopiedShareableUrl(false), 2000)
|
||||
|
||||
@@ -32,7 +32,16 @@ export default function RelaySimpleInfo({
|
||||
</div>
|
||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground">{t('Favorited by')} </div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -15,9 +18,9 @@ import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import StuffStats from '../StuffStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import StuffStats from '../StuffStats'
|
||||
import TrustScoreBadge from '../TrustScoreBadge'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
@@ -25,18 +28,23 @@ export default function ReplyNote({
|
||||
event,
|
||||
parentEventId,
|
||||
onClickParent = () => {},
|
||||
highlight = false
|
||||
highlight = false,
|
||||
className = ''
|
||||
}: {
|
||||
event: Event
|
||||
parentEventId?: string
|
||||
onClickParent?: () => void
|
||||
highlight?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||
const replies = useThread(eventKey)
|
||||
const [showMuted, setShowMuted] = useState(false)
|
||||
const show = useMemo(() => {
|
||||
if (showMuted) {
|
||||
@@ -50,12 +58,35 @@ export default function ReplyNote({
|
||||
}
|
||||
return true
|
||||
}, [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 (
|
||||
<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))}
|
||||
>
|
||||
{hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
|
||||
<Collapsible>
|
||||
<div className="flex space-x-2 items-start px-4 pt-3">
|
||||
<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"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
|
||||
<ClientTag event={event} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -79,10 +111,7 @@ export default function ReplyNote({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0">
|
||||
<TranslateButton event={event} className="py-0" />
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||
</div>
|
||||
{parentEventId && (
|
||||
<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 {
|
||||
getEventKey,
|
||||
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 { useAllDescendantThreads } from '@/hooks/useThread'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { Event as NEvent } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingBar } from '../LoadingBar'
|
||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||
|
||||
type TRootInfo =
|
||||
| { type: 'E'; id: string; pubkey: string }
|
||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
||||
| { type: 'I'; id: string }
|
||||
import SubReplies from './SubReplies'
|
||||
|
||||
const LIMIT = 100
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function ReplyNoteList({
|
||||
stuff,
|
||||
index
|
||||
}: {
|
||||
stuff: NEvent | string
|
||||
index?: number
|
||||
}) {
|
||||
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push, currentIndex } = useSecondaryPage()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
||||
const { repliesMap, addReplies } = useReply()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const { stuffKey } = useStuff(stuff)
|
||||
const allThreads = useAllDescendantThreads(stuffKey)
|
||||
const [initialLoading, setInitialLoading] = useState(false)
|
||||
|
||||
const replies = useMemo(() => {
|
||||
const replyKeySet = new Set<string>()
|
||||
const replyEvents: NEvent[] = []
|
||||
|
||||
let parentKeys = [stuffKey]
|
||||
while (parentKeys.length > 0) {
|
||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
events.forEach((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
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] }
|
||||
const thread = allThreads.get(stuffKey) || []
|
||||
const replyEvents = thread.filter((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
if (replyKeySet.has(key)) return false
|
||||
if (mutePubkeySet.has(evt.pubkey)) return false
|
||||
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
|
||||
return false
|
||||
}
|
||||
setRootInfo(root)
|
||||
}
|
||||
fetchRootEvent()
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !rootInfo || currentIndex !== index) return
|
||||
|
||||
const init = async () => {
|
||||
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
|
||||
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 false
|
||||
}
|
||||
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()
|
||||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [rootInfo, currentIndex, index])
|
||||
replyKeySet.add(key)
|
||||
return true
|
||||
})
|
||||
return replyEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [
|
||||
stuffKey,
|
||||
allThreads,
|
||||
mutePubkeySet,
|
||||
hideContentMentioningMutedUsers,
|
||||
hideUntrustedInteractions,
|
||||
isUserTrusted
|
||||
])
|
||||
|
||||
// Initial subscription
|
||||
useEffect(() => {
|
||||
if (replies.length === 0) {
|
||||
loadMore()
|
||||
}
|
||||
}, [replies])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
const loadInitial = async () => {
|
||||
setInitialLoading(true)
|
||||
await threadService.subscribe(stuff, LIMIT)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && showCount < replies.length) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
loadInitial()
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
threadService.unsubscribe(stuff)
|
||||
}
|
||||
}, [replies, showCount])
|
||||
}, [stuff])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || !until || !timelineKey) return
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
return await threadService.loadMore(stuff, LIMIT)
|
||||
}, [stuff])
|
||||
|
||||
setLoading(true)
|
||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
addReplies(events)
|
||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||
setLoading(false)
|
||||
}, [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)
|
||||
}, [])
|
||||
const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||
items: replies,
|
||||
showCount: SHOW_COUNT,
|
||||
onLoadMore: handleLoadMore,
|
||||
initialLoading
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{loading && <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>
|
||||
)}
|
||||
{(loading || initialLoading) && <LoadingBar />}
|
||||
<div>
|
||||
{replies.slice(0, showCount).map((reply) => {
|
||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{visibleItems.map((reply) => (
|
||||
<Item key={reply.id} reply={reply} />
|
||||
))}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div ref={bottomRef} />
|
||||
{shouldShowLoadingIndicator ? (
|
||||
<ReplyNoteSkeleton />
|
||||
) : (
|
||||
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
||||
{replies.length > 0 ? t('no more replies') : t('no replies')}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
{loading && <ReplyNoteSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ reply }: { reply: NEvent }) {
|
||||
const key = useMemo(() => getEventKey(reply), [reply])
|
||||
|
||||
return (
|
||||
<div className="relative border-b">
|
||||
<ReplyNote event={reply} />
|
||||
<SubReplies parentKey={key} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +1,586 @@
|
||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||
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 {
|
||||
toAppearanceSettings,
|
||||
toEmojiPackSettings,
|
||||
toGeneralSettings,
|
||||
toPostSettings,
|
||||
toRelaySettings,
|
||||
toSystemSettings,
|
||||
toTranslation,
|
||||
toWallet
|
||||
} from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
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 { 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 {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Cog,
|
||||
Columns2,
|
||||
Copy,
|
||||
Info,
|
||||
KeyRound,
|
||||
Languages,
|
||||
LayoutList,
|
||||
List,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
PanelLeft,
|
||||
PencilLine,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings2,
|
||||
Smile,
|
||||
Sun,
|
||||
Wallet
|
||||
} 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'
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [copiedNsec, setCopiedNsec] = 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 (
|
||||
<div>
|
||||
<SettingItem className="clickable" onClick={() => push(toGeneralSettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Settings2 />
|
||||
<div>{t('General')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
<SettingItem className="clickable" onClick={() => push(toAppearanceSettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Palette />
|
||||
<div>{t('Appearance')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
<SettingItem className="clickable" onClick={() => push(toRelaySettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Server />
|
||||
<div>{t('Relays')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
{!!pubkey && (
|
||||
<SettingItem className="clickable" onClick={() => push(toTranslation())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Languages />
|
||||
<div>{t('Translation')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
)}
|
||||
{!!pubkey && (
|
||||
<SettingItem className="clickable" onClick={() => push(toWallet())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Wallet />
|
||||
<div>{t('Wallet')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
)}
|
||||
{!!pubkey && (
|
||||
<SettingItem className="clickable" onClick={() => push(toPostSettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<PencilLine />
|
||||
<div>{t('Post settings')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
)}
|
||||
{!!pubkey && (
|
||||
<SettingItem className="clickable" onClick={() => push(toEmojiPackSettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Smile />
|
||||
<div>{t('Emoji Packs')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
)}
|
||||
<div ref={accordionRef}>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
value={openSection}
|
||||
onValueChange={handleAccordionChange}
|
||||
className="w-full"
|
||||
>
|
||||
{/* General */}
|
||||
<AccordionItem value="general">
|
||||
<AccordionTrigger className="px-4 hover:no-underline">
|
||||
<div className="flex items-center gap-4">
|
||||
<Settings2 className="size-4" />
|
||||
<span>{t('General')}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 space-y-4">
|
||||
<SettingItem>
|
||||
<Label htmlFor="languages" className="text-base font-normal">
|
||||
{t('Languages')}
|
||||
</Label>
|
||||
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger id="languages" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(LocalizedLanguageNames).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingItem>
|
||||
<SettingItem>
|
||||
<Label htmlFor="media-auto-load-policy" className="text-base font-normal">
|
||||
{t('Auto-load media')}
|
||||
</Label>
|
||||
<Select
|
||||
defaultValue="wifi-only"
|
||||
value={mediaAutoLoadPolicy}
|
||||
onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
|
||||
>
|
||||
<SelectTrigger id="media-auto-load-policy" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
|
||||
{isSupportCheckConnectionType() && (
|
||||
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
|
||||
)}
|
||||
<SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 && (
|
||||
<SettingItem
|
||||
className="clickable"
|
||||
@@ -129,13 +613,6 @@ export default function Settings() {
|
||||
{copiedNcryptsec ? <Check /> : <Copy />}
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem className="clickable" onClick={() => push(toSystemSettings())}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Cog />
|
||||
<div>{t('System')}</div>
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</SettingItem>
|
||||
<AboutInfoDialog>
|
||||
<SettingItem className="clickable">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -146,7 +623,6 @@ export default function Settings() {
|
||||
<div className="text-muted-foreground">
|
||||
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
|
||||
</div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</SettingItem>
|
||||
</AboutInfoDialog>
|
||||
@@ -162,7 +638,7 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -174,3 +650,28 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
||||
}
|
||||
)
|
||||
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 { useAllDescendantThreads } from '@/hooks/useThread'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, checkLogin } = useNostr()
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const { repliesMap } = useReply()
|
||||
const allThreads = useAllDescendantThreads(stuffKey)
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { replyCount, hasReplied } = useMemo(() => {
|
||||
const hasReplied = pubkey
|
||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
|
||||
: false
|
||||
|
||||
let replyCount = 0
|
||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
||||
const replies = [...(allThreads.get(stuffKey) ?? [])]
|
||||
while (replies.length > 0) {
|
||||
const reply = replies.pop()
|
||||
if (!reply) break
|
||||
|
||||
const replyKey = getEventKey(reply)
|
||||
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
|
||||
const nestedReplies = allThreads.get(replyKey) ?? []
|
||||
replies.push(...nestedReplies)
|
||||
|
||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
}
|
||||
|
||||
return { replyCount, hasReplied }
|
||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
||||
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
|
||||
const dismissed = storage.getDismissedTooManyRelaysAlert()
|
||||
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)
|
||||
} else {
|
||||
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 { useNostr } from '@/providers/NostrProvider'
|
||||
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
||||
import { TFeedSubRequest } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
|
||||
const { pinnedPubkeySet } = usePinnedUsers()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { addReplies } = useReply()
|
||||
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setHasMore(events.length > 0)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
setNewEvents((oldEvents) =>
|
||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||
)
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
},
|
||||
onClose: (url, reason) => {
|
||||
if (!showRelayCloseReason) return
|
||||
|
||||
@@ -68,9 +68,9 @@ export default function WebPreview({
|
||||
{image && (
|
||||
<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={{
|
||||
skeleton: 'rounded-none border-r'
|
||||
wrapper: 'rounded-none border-r'
|
||||
}}
|
||||
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',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 = [
|
||||
'https://blossom.band/',
|
||||
@@ -62,12 +62,17 @@ export const ApplicationDataKey = {
|
||||
|
||||
export const BIG_RELAY_URLS = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/',
|
||||
'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/']
|
||||
|
||||
@@ -134,8 +139,8 @@ export const YOUTUBE_URL_REGEX =
|
||||
export const X_URL_REGEX =
|
||||
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi
|
||||
|
||||
export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
|
||||
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'
|
||||
export const SMESH_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
|
||||
export const CODY_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
|
||||
|
||||
export const NIP_96_SERVICE = [
|
||||
'https://mockingyou.com',
|
||||
|
||||
@@ -5,5 +5,5 @@ export * from './useFetchProfile'
|
||||
export * from './useFetchRelayInfo'
|
||||
export * from './useFetchRelayInfos'
|
||||
export * from './useFetchRelayList'
|
||||
export * from './useInfiniteScroll'
|
||||
export * from './useSearchProfiles'
|
||||
export * from './useTranslatedEvent'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -7,7 +6,6 @@ import { useEffect, useState } from 'react'
|
||||
export function useFetchEvent(eventId?: string) {
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const { addReplies } = useReply()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [event, setEvent] = useState<Event | undefined>(undefined)
|
||||
|
||||
@@ -23,7 +21,6 @@ export function useFetchEvent(eventId?: string) {
|
||||
const event = await client.fetchEvent(eventId)
|
||||
if (event && !isEventDeleted(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 th from './locales/th'
|
||||
import zh from './locales/zh'
|
||||
import zh_TW from './locales/zh-TW'
|
||||
|
||||
const languages = {
|
||||
ar: { resource: ar, name: 'العربية' },
|
||||
@@ -37,7 +38,8 @@ const languages = {
|
||||
'pt-PT': { resource: pt_PT, name: 'Português (Portugal)' },
|
||||
ru: { resource: ru, name: 'Русский' },
|
||||
th: { resource: th, name: 'ไทย' },
|
||||
zh: { resource: zh, name: '简体中文' }
|
||||
zh: { resource: zh, name: '简体中文' },
|
||||
'zh-TW': { resource: zh_TW, name: '繁體中文' }
|
||||
} as const
|
||||
|
||||
export type TLanguage = keyof typeof languages
|
||||
@@ -62,6 +64,10 @@ i18n
|
||||
},
|
||||
detection: {
|
||||
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))
|
||||
return supported || 'en'
|
||||
}
|
||||
@@ -71,6 +77,7 @@ i18n
|
||||
i18n.services.formatter?.add('date', (timestamp, lng) => {
|
||||
switch (lng) {
|
||||
case 'zh':
|
||||
case 'zh-TW':
|
||||
case 'ja':
|
||||
return dayjs(timestamp).format('YYYY年MM月DD日')
|
||||
case 'pl':
|
||||
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
'Add an Account': 'إضافة حساب',
|
||||
'More options': 'المزيد من الخيارات',
|
||||
'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?': 'هل أنت متأكد أنك تريد تسجيل الخروج؟',
|
||||
'relay sets': 'مجموعات الريلاي',
|
||||
edit: 'تعديل',
|
||||
@@ -195,9 +195,9 @@ export default {
|
||||
All: 'الكل',
|
||||
Reactions: 'التفاعلات',
|
||||
Zaps: 'Zaps',
|
||||
'Enjoying Jumble?': 'هل تستمتع بـ Jumble؟',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'تبرعك يساعد في صيانة Jumble وتحسينه! 😊',
|
||||
'Enjoying Smesh?': 'هل تستمتع بـ Smesh؟',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'تبرعك يساعد في صيانة Smesh وتحسينه! 😊',
|
||||
'Earlier notifications': 'الإشعارات السابقة',
|
||||
'Temporarily display this note': 'عرض هذه الملاحظة مؤقتاً',
|
||||
buttonFollowing: 'جارٍ المتابعة',
|
||||
@@ -250,7 +250,7 @@ export default {
|
||||
Translation: 'الترجمة',
|
||||
Balance: 'الرصيد',
|
||||
characters: 'الحروف',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'يمكنك استخدام مفتاح API هذا في أي مكان آخر يدعم LibreTranslate. عنوان الخدمة هو {{serviceUrl}}',
|
||||
'Top up': 'إعادة شحن',
|
||||
'Will receive: {n} characters': 'ستتلقى: {{n}} حروف',
|
||||
@@ -384,6 +384,7 @@ export default {
|
||||
'reacted to your note': 'تفاعل مع ملاحظتك',
|
||||
'reposted your note': 'أعاد نشر ملاحظتك',
|
||||
'zapped your note': 'زاب ملاحظتك',
|
||||
'highlighted your note': 'أبرز ملاحظتك',
|
||||
'zapped you': 'زابك',
|
||||
'Mark as read': 'تعليم كمقروء',
|
||||
Report: 'تبليغ',
|
||||
@@ -488,14 +489,14 @@ export default {
|
||||
Remote: 'عن بُعد',
|
||||
'Encrypted Key': 'مفتاح مشفر',
|
||||
'Private Key': 'مفتاح خاص',
|
||||
'Welcome to Jumble': 'مرحبًا بك في Jumble',
|
||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Jumble هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.',
|
||||
'Welcome to Smesh': 'مرحبًا بك في Smesh',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Smesh هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.',
|
||||
'Explore Relays': 'استكشف المرحلات',
|
||||
'Choose a feed': 'اختر خلاصة',
|
||||
'and {{x}} others': 'و {{x}} آخرون',
|
||||
selfZapWarning:
|
||||
'Jumble غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡',
|
||||
'Smesh غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡',
|
||||
'Emoji Pack': 'حزمة الرموز التعبيرية',
|
||||
'Emoji pack added': 'تمت إضافة حزمة الرموز التعبيرية',
|
||||
'Add emoji pack failed': 'فشل إضافة حزمة الرموز التعبيرية',
|
||||
@@ -583,6 +584,56 @@ export default {
|
||||
'Special Follow': 'متابعة خاصة',
|
||||
'Unfollow Special': 'إلغاء المتابعة الخاصة',
|
||||
'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',
|
||||
'More options': 'Mehr Optionen',
|
||||
'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?',
|
||||
'relay sets': 'Relay-Sets',
|
||||
edit: 'bearbeiten',
|
||||
@@ -199,9 +199,9 @@ export default {
|
||||
All: 'Alle',
|
||||
Reactions: 'Reaktionen',
|
||||
Zaps: 'Zaps',
|
||||
'Enjoying Jumble?': 'Gefällt dir Jumble?',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! 😊',
|
||||
'Enjoying Smesh?': 'Gefällt dir Smesh?',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'Deine Spende hilft mir, Smesh zu pflegen und zu verbessern! 😊',
|
||||
'Earlier notifications': 'Frühere Benachrichtigungen',
|
||||
'Temporarily display this note': 'Notiz vorübergehend anzeigen',
|
||||
buttonFollowing: 'Folge',
|
||||
@@ -257,7 +257,7 @@ export default {
|
||||
Translation: 'Übersetzung',
|
||||
Balance: 'Guthaben',
|
||||
characters: 'Zeichen',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'Du kannst diesen API-Schlüssel überall dort verwenden, wo LibreTranslate unterstützt wird. Die Service-URL ist {{serviceUrl}}',
|
||||
'Top up': 'Aufladen',
|
||||
'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen',
|
||||
@@ -393,6 +393,7 @@ export default {
|
||||
'reacted to your note': 'hat auf Ihre Notiz reagiert',
|
||||
'reposted your note': 'hat Ihre Notiz geteilt',
|
||||
'zapped your note': 'hat Ihre Notiz gezappt',
|
||||
'highlighted your note': 'hat Ihre Notiz hervorgehoben',
|
||||
'zapped you': 'hat Sie gezappt',
|
||||
'Mark as read': 'Als gelesen markieren',
|
||||
Report: 'Melden',
|
||||
@@ -502,14 +503,14 @@ export default {
|
||||
Remote: 'Remote',
|
||||
'Encrypted Key': 'Verschlüsselter Schlüssel',
|
||||
'Private Key': 'Privater Schlüssel',
|
||||
'Welcome to Jumble': 'Willkommen bei Jumble',
|
||||
'Jumble 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.',
|
||||
'Welcome to Smesh': 'Willkommen bei Smesh',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'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',
|
||||
'Choose a feed': 'Wähle einen Feed',
|
||||
'and {{x}} others': 'und {{x}} andere',
|
||||
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 added': 'Emoji-Paket hinzugefügt',
|
||||
'Add emoji pack failed': 'Hinzufügen des Emoji-Pakets fehlgeschlagen',
|
||||
@@ -599,6 +600,61 @@ export default {
|
||||
'Special Follow': 'Besonders Folgen',
|
||||
'Unfollow Special': 'Besonders Entfolgen',
|
||||
'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',
|
||||
'More options': 'More options',
|
||||
'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?',
|
||||
'relay sets': 'relay sets',
|
||||
edit: 'edit',
|
||||
@@ -196,9 +196,9 @@ export default {
|
||||
All: 'All',
|
||||
Reactions: 'Reactions',
|
||||
Zaps: 'Zaps',
|
||||
'Enjoying Jumble?': 'Enjoying Jumble?',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'Your donation helps me maintain Jumble and make it better! 😊',
|
||||
'Enjoying Smesh?': 'Enjoying Smesh?',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'Your donation helps me maintain Smesh and make it better! 😊',
|
||||
'Earlier notifications': 'Earlier notifications',
|
||||
'Temporarily display this note': 'Temporarily display this note',
|
||||
buttonFollowing: 'Following',
|
||||
@@ -233,7 +233,6 @@ export default {
|
||||
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?',
|
||||
'Platinum Sponsors': 'Platinum Sponsors',
|
||||
From: 'From',
|
||||
'Comment on': 'Comment on',
|
||||
'View on njump.me': 'View on njump.me',
|
||||
@@ -247,23 +246,6 @@ export default {
|
||||
'Lightning Invoice': 'Lightning Invoice',
|
||||
'Bookmark failed': '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',
|
||||
interactions: 'interactions',
|
||||
notifications: 'notifications',
|
||||
@@ -280,11 +262,10 @@ export default {
|
||||
Continue: 'Continue',
|
||||
'Successfully updated mute list': 'Successfully updated mute list',
|
||||
'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
|
||||
'Translating...': 'Translating...',
|
||||
Translate: 'Translate',
|
||||
'Show original': 'Show original',
|
||||
Website: 'Website',
|
||||
'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',
|
||||
Community: 'Community',
|
||||
Group: 'Group',
|
||||
@@ -383,6 +364,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': 'highlighted your note',
|
||||
'zapped you': 'zapped you',
|
||||
'Mark as read': 'Mark as read',
|
||||
Report: 'Report',
|
||||
@@ -446,6 +428,7 @@ export default {
|
||||
'Connect to your Rizful Vault': 'Connect to your Rizful Vault',
|
||||
'Paste your one-time code here': 'Paste your one-time code here',
|
||||
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': 'Set up',
|
||||
Pinned: 'Pinned',
|
||||
@@ -488,14 +471,14 @@ export default {
|
||||
Remote: 'Remote',
|
||||
'Encrypted Key': 'Encrypted Key',
|
||||
'Private Key': 'Private Key',
|
||||
'Welcome to Jumble': 'Welcome to Jumble',
|
||||
'Jumble 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.',
|
||||
'Welcome to Smesh': 'Welcome to Smesh',
|
||||
'Smesh 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',
|
||||
'Choose a feed': 'Choose a feed',
|
||||
'and {{x}} others': 'and {{x}} others',
|
||||
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 added': 'Emoji pack added',
|
||||
'Add emoji pack failed': 'Add emoji pack failed',
|
||||
@@ -586,6 +569,58 @@ export default {
|
||||
'Special Follow': 'Special Follow',
|
||||
'Unfollow Special': 'Unfollow Special',
|
||||
'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',
|
||||
'More options': 'Más opciones',
|
||||
'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?',
|
||||
'relay sets': 'conjuntos de relés',
|
||||
edit: 'editar',
|
||||
@@ -199,9 +199,9 @@ export default {
|
||||
All: 'Todo',
|
||||
Reactions: 'Reacciones',
|
||||
Zaps: 'Zaps',
|
||||
'Enjoying Jumble?': '¿Te gusta Jumble?',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'¡Tu donación me ayuda a mantener y mejorar Jumble! 😊',
|
||||
'Enjoying Smesh?': '¿Te gusta Smesh?',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'¡Tu donación me ayuda a mantener y mejorar Smesh! 😊',
|
||||
'Earlier notifications': 'Notificaciones anteriores',
|
||||
'Temporarily display this note': 'Mostrar esta nota temporalmente',
|
||||
buttonFollowing: 'Siguiendo',
|
||||
@@ -255,7 +255,7 @@ export default {
|
||||
Translation: 'Traducción',
|
||||
Balance: 'Saldo',
|
||||
characters: 'caracteres',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}',
|
||||
'Top up': 'Recargar',
|
||||
'Will receive: {n} characters': 'Recibirás: {{n}} caracteres',
|
||||
@@ -389,6 +389,7 @@ export default {
|
||||
'reacted to your note': 'reaccionó a tu nota',
|
||||
'reposted your note': 'reposteó tu nota',
|
||||
'zapped your note': 'zappeó tu nota',
|
||||
'highlighted your note': 'destacó tu nota',
|
||||
'zapped you': 'te zappeó',
|
||||
'Mark as read': 'Marcar como leído',
|
||||
Report: 'Reportar',
|
||||
@@ -496,14 +497,14 @@ export default {
|
||||
Remote: 'Remoto',
|
||||
'Encrypted Key': 'Clave privada cifrada',
|
||||
'Private Key': 'Clave privada',
|
||||
'Welcome to Jumble': 'Bienvenido a Jumble',
|
||||
'Jumble 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.',
|
||||
'Welcome to Smesh': 'Bienvenido a Smesh',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'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',
|
||||
'Choose a feed': 'Elige un feed',
|
||||
'and {{x}} others': 'y {{x}} otros',
|
||||
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 added': 'Paquete de emojis añadido',
|
||||
'Add emoji pack failed': 'Error al añadir paquete de emojis',
|
||||
@@ -595,6 +596,59 @@ export default {
|
||||
'Special Follow': 'Seguir Especial',
|
||||
'Unfollow Special': 'Dejar de Seguir Especial',
|
||||
'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': 'افزودن حساب',
|
||||
'More options': 'گزینههای بیشتر',
|
||||
'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?': 'آیا مطمئن هستید که میخواهید خارج شوید؟',
|
||||
'relay sets': 'مجموعههای رله',
|
||||
edit: 'ویرایش',
|
||||
@@ -197,9 +197,9 @@ export default {
|
||||
All: 'همه',
|
||||
Reactions: 'واکنشها',
|
||||
Zaps: 'زپها',
|
||||
'Enjoying Jumble?': 'از Jumble لذت میبرید؟',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'کمک مالی شما به من در نگهداری Jumble و بهتر کردن آن کمک میکند! 😊',
|
||||
'Enjoying Smesh?': 'از Smesh لذت میبرید؟',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'کمک مالی شما به من در نگهداری Smesh و بهتر کردن آن کمک میکند! 😊',
|
||||
'Earlier notifications': 'اعلانهای قبلی',
|
||||
'Temporarily display this note': 'نمایش موقت این یادداشت',
|
||||
buttonFollowing: 'دنبال میکنم',
|
||||
@@ -252,7 +252,7 @@ export default {
|
||||
Translation: 'ترجمه',
|
||||
Balance: 'موجودی',
|
||||
characters: 'کاراکتر',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'میتوانید از این کلید API در هر جای دیگری که از LibreTranslate پشتیبانی میکند استفاده کنید. آدرس سرویس {{serviceUrl}} است',
|
||||
'Top up': 'شارژ',
|
||||
'Will receive: {n} characters': 'دریافت خواهید کرد: {{n}} کاراکتر',
|
||||
@@ -385,6 +385,7 @@ export default {
|
||||
'reacted to your note': 'به یادداشت شما واکنش نشان داد',
|
||||
'reposted your note': 'یادداشت شما را بازنشر کرد',
|
||||
'zapped your note': 'یادداشت شما را زپ کرد',
|
||||
'highlighted your note': 'یادداشت شما را برجسته کرد',
|
||||
'zapped you': 'شما را زپ کرد',
|
||||
'Mark as read': 'علامتگذاری به عنوان خوانده شده',
|
||||
Report: 'گزارش',
|
||||
@@ -491,14 +492,14 @@ export default {
|
||||
Remote: 'از راه دور',
|
||||
'Encrypted Key': 'رمزگذاری شده کلید',
|
||||
'Private Key': 'کلید خصوصی',
|
||||
'Welcome to Jumble': 'به Jumble خوش آمدید',
|
||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Jumble یک کلاینت متمرکز بر مرور رلههاست. با کاوش در رلههای جالب شروع کنید یا وارد شوید تا فید دنبالکنندههای خود را مشاهده کنید.',
|
||||
'Welcome to Smesh': 'به Smesh خوش آمدید',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Smesh یک کلاینت متمرکز بر مرور رلههاست. با کاوش در رلههای جالب شروع کنید یا وارد شوید تا فید دنبالکنندههای خود را مشاهده کنید.',
|
||||
'Explore Relays': 'کاوش در رلهها',
|
||||
'Choose a feed': 'یک فید انتخاب کنید',
|
||||
'and {{x}} others': 'و {{x}} دیگر',
|
||||
selfZapWarning:
|
||||
'Jumble مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان میافتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡',
|
||||
'Smesh مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان میافتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡',
|
||||
'Emoji Pack': 'بسته ایموجی',
|
||||
'Emoji pack added': 'بسته ایموجی اضافه شد',
|
||||
'Add emoji pack failed': 'افزودن بسته ایموجی ناموفق بود',
|
||||
@@ -589,6 +590,60 @@ export default {
|
||||
'Special Follow': 'دنبال کردن ویژه',
|
||||
'Unfollow Special': 'لغو دنبال کردن ویژه',
|
||||
'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',
|
||||
'More options': "Plus d'options",
|
||||
'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 ?',
|
||||
'relay sets': 'groupes de relais',
|
||||
edit: 'modifier',
|
||||
@@ -198,9 +198,9 @@ export default {
|
||||
All: 'Tous',
|
||||
Reactions: 'Réactions',
|
||||
Zaps: 'Zaps',
|
||||
'Enjoying Jumble?': 'Vous appréciez Jumble ?',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
"Votre don m'aide à maintenir Jumble et à l'améliorer ! 😊",
|
||||
'Enjoying Smesh?': 'Vous appréciez Smesh ?',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
"Votre don m'aide à maintenir Smesh et à l'améliorer ! 😊",
|
||||
'Earlier notifications': 'Notifications antérieures',
|
||||
'Temporarily display this note': 'Afficher temporairement cette note',
|
||||
buttonFollowing: 'Suivi',
|
||||
@@ -255,7 +255,7 @@ export default {
|
||||
Translation: 'Traduction',
|
||||
Balance: 'Solde',
|
||||
characters: 'caractères',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'Vous pouvez utiliser cette clé API ailleurs qui prend en charge LibreTranslate. L’URL du service est {{serviceUrl}}',
|
||||
'Top up': 'Recharger',
|
||||
'Will receive: {n} characters': 'Vous recevrez : {{n}} caractères',
|
||||
@@ -393,6 +393,7 @@ export default {
|
||||
'reacted to your note': 'a réagi à votre note',
|
||||
'reposted your note': 'a repartagé votre note',
|
||||
'zapped your note': 'a zappé votre note',
|
||||
'highlighted your note': 'a mis en évidence votre note',
|
||||
'zapped you': 'vous a zappé',
|
||||
'Mark as read': 'Marquer comme lu',
|
||||
Report: 'Signaler',
|
||||
@@ -501,14 +502,14 @@ export default {
|
||||
Remote: 'Distant',
|
||||
'Encrypted Key': 'Clé chiffrée',
|
||||
'Private Key': 'Clé privée',
|
||||
'Welcome to Jumble': 'Bienvenue sur Jumble',
|
||||
'Jumble 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.",
|
||||
'Welcome to Smesh': 'Bienvenue sur Smesh',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
"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',
|
||||
'Choose a feed': 'Choisir un fil',
|
||||
'and {{x}} others': 'et {{x}} autres',
|
||||
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 added': "Pack d'emojis ajouté",
|
||||
'Add emoji pack failed': "Échec de l'ajout du pack d'emojis",
|
||||
@@ -598,6 +599,59 @@ export default {
|
||||
'Special Follow': 'Suivre Spécial',
|
||||
'Unfollow Special': 'Ne Plus Suivre Spécial',
|
||||
'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': 'अकाउंट जोड़ें',
|
||||
'More options': 'अधिक विकल्प',
|
||||
'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?': 'क्या आप वाकई लॉगआउट करना चाहते हैं?',
|
||||
'relay sets': 'रिले सेट',
|
||||
edit: 'संपादित करें',
|
||||
@@ -198,9 +198,9 @@ export default {
|
||||
All: 'सभी',
|
||||
Reactions: 'प्रतिक्रियाएं',
|
||||
Zaps: 'जैप्स',
|
||||
'Enjoying Jumble?': 'Jumble का आनंद ले रहे हैं?',
|
||||
'Your donation helps me maintain Jumble and make it better! 😊':
|
||||
'आपका दान मुझे Jumble को बनाए रखने और इसे बेहतर बनाने में मदद करता है! 😊',
|
||||
'Enjoying Smesh?': 'Smesh का आनंद ले रहे हैं?',
|
||||
'Your donation helps me maintain Smesh and make it better! 😊':
|
||||
'आपका दान मुझे Smesh को बनाए रखने और इसे बेहतर बनाने में मदद करता है! 😊',
|
||||
'Earlier notifications': 'पुरानी सूचनाएं',
|
||||
'Temporarily display this note': 'इस नोट को अस्थायी रूप से प्रदर्शित करें',
|
||||
buttonFollowing: 'फॉलो कर रहे हैं',
|
||||
@@ -253,7 +253,7 @@ export default {
|
||||
Translation: 'अनुवाद',
|
||||
Balance: 'बैलेंस',
|
||||
characters: 'अक्षर',
|
||||
jumbleTranslateApiKeyDescription:
|
||||
smeshTranslateApiKeyDescription:
|
||||
'आप इस API की को कहीं भी उपयोग कर सकते हैं जो LibreTranslate का समर्थन करता है। सेवा URL है {{serviceUrl}}',
|
||||
'Top up': 'टॉप अप',
|
||||
'Will receive: {n} characters': 'प्राप्त होंगे: {{n}} अक्षर',
|
||||
@@ -388,6 +388,7 @@ export default {
|
||||
'reacted to your note': 'ने आपके नोट पर प्रतिक्रिया दी',
|
||||
'reposted your note': 'ने आपके नोट को रीपोस्ट किया',
|
||||
'zapped your note': 'ने आपके नोट को जैप किया',
|
||||
'highlighted your note': 'ने आपके नोट को हाइलाइट किया',
|
||||
'zapped you': 'ने आपको जैप किया',
|
||||
'Mark as read': 'पढ़ा हुआ मार्क करें',
|
||||
Report: 'रिपोर्ट करें',
|
||||
@@ -493,14 +494,14 @@ export default {
|
||||
Remote: 'रिमोट',
|
||||
'Encrypted Key': 'एन्क्रिप्टेड की',
|
||||
'Private Key': 'प्राइवेट की',
|
||||
'Welcome to Jumble': 'Jumble में आपका स्वागत है',
|
||||
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Jumble एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।',
|
||||
'Welcome to Smesh': 'Smesh में आपका स्वागत है',
|
||||
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
|
||||
'Smesh एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।',
|
||||
'Explore Relays': 'रिले एक्सप्लोर करें',
|
||||
'Choose a feed': 'एक फीड चुनें',
|
||||
'and {{x}} others': 'और {{x}} अन्य',
|
||||
selfZapWarning:
|
||||
'Jumble आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡',
|
||||
'Smesh आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡',
|
||||
'Emoji Pack': 'इमोजी पैक',
|
||||
'Emoji pack added': 'इमोजी पैक जोड़ा गया',
|
||||
'Add emoji pack failed': 'इमोजी पैक जोड़ना विफल रहा',
|
||||
@@ -590,6 +591,60 @@ export default {
|
||||
'Special Follow': 'विशेष फ़ॉलो',
|
||||
'Unfollow Special': 'विशेष अनफ़ॉलो',
|
||||
'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: 'अनुशंसित'
|
||||
}
|
||||
}
|
||||
|
||||