33 Commits

Author SHA1 Message Date
mleku
6a7bfe0a3e refactor: update branding assets and convert settings to accordion UI
- Replace SVG favicons with PNG icons from new smeshicon assets
- Add theme-aware Icon component using smeshiconlight/dark PNGs
- Refactor Settings page to use collapsible accordion sections
- Add Radix UI accordion component with animations
- Update QrCode component to use new PNG icon
- Remove old favicon.svg and nostr.json files
- Add new logo assets in resources/ and src/assets/

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 05:10:41 +02:00
mleku
3348e11796 refactor: update donation/about to new npub and remove platinum sponsors
- Update SMESH_PUBKEY and CODY_PUBKEY to npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku
- Remove PlatinumSponsors component and OpenSats logo
- Remove Platinum Sponsors i18n string

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 04:44:38 +02:00
mleku
ad6a3dbbab refactor: use domain objects for FollowList and MuteList providers
- Refactor FollowListProvider to use domain FollowList class
- Refactor MuteListProvider to use domain MuteList class
- Add hide untrusted interactions/notifications settings to GeneralSettingsPage
- Maintain backward compatibility with existing Set<string> interface

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 04:13:12 +02:00
mleku
bb74308e28 refactor: remove translation feature
Remove the translation feature including:
- Translation services (Smesh API and LibreTranslate)
- TranslationServiceProvider and context
- TranslateButton component
- Translation settings page
- useTranslatedEvent hook
- Translation-related types and i18n strings

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 04:11:24 +02:00
mleku
0c6de715c4 docs: add Domain-Driven Design analysis report
Comprehensive DDD analysis of the Smesh codebase including:
- Domain and bounded context identification
- Anti-pattern analysis with remediation strategies
- 5-phase refactoring roadmap
- Migration strategy and success metrics

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:28:32 +02:00
mleku
13b3b82443 refactor: rebrand from Jumble to Smesh
- Replace all Jumble branding with Smesh throughout codebase
- Add new Smesh logo images (light/dark themes)
- Update Logo component to use PNG images with theme support
- Update URLs to git.mleku.dev/mleku/smesh
- Rename JumbleTranslate to SmeshTranslate
- Update all i18n locale files with new branding
- Add system theme detection CSS to prevent flash on load
- Update PWA manifest, docker-compose, and config files

ЁЯдЦ Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 09:43:18 +02:00
codytseng
e60a460480 fix: adjust button layout for download and copy actions in Signup component 2025-12-26 09:29:50 +08:00
bitcoinuser
81667112d1 feat: update Portuguese translations for backup messages (#705) 2025-12-25 23:18:26 +08:00
codytseng
c60d7ab401 feat: adjust default relay configuration 2025-12-25 23:14:52 +08:00
codytseng
e25902b8b4 refactor: ЁЯПЧя╕П 2025-12-25 23:03:44 +08:00
codytseng
d964c7b7b3 fix: return 0 instead of null for missing user percentile data 2025-12-25 09:21:29 +08:00
codytseng
25b2831fcc feat: ЁЯТи 2025-12-24 23:31:18 +08:00
bitcoinuser
1553227e13 feat: improve signup copy in Portuguese translations (#703) 2025-12-24 22:58:26 +08:00
codytseng
f04981f5b9 fix: improve description display in RelaySimpleInfo component 2025-12-24 22:54:58 +08:00
codytseng
2662373704 fix: adjust layout for Signup component 2025-12-24 22:51:59 +08:00
codytseng
526b64aec0 feat: add border to image hash placeholder 2025-12-24 22:48:38 +08:00
codytseng
41a65338b5 fix: ЁЯРЫ 2025-12-24 22:30:00 +08:00
codytseng
56f0aa9fd5 fix: ЁЯРЫ 2025-12-24 13:22:38 +08:00
codytseng
89f79b999c refactor: reverse top-level replies order 2025-12-24 13:01:03 +08:00
bitcoinuser
7459a3d33a feat: update Portuguese translations for clarity and accuracy (#702) 2025-12-24 10:58:24 +08:00
codytseng
49eca495f5 refactor: ЁЯОи 2025-12-24 10:55:05 +08:00
codytseng
96abe5f24f feat: add compatibility for legacy comments 2025-12-23 23:30:57 +08:00
codytseng
0ee93718da feat: add relay recommendations based on user language 2025-12-23 22:28:07 +08:00
codytseng
a880a92748 feat: simplify account creation flow 2025-12-23 21:52:32 +08:00
codytseng
cd7c52eda0 feat: batch fetch user percentiles 2025-12-22 22:34:29 +08:00
codytseng
ef6d44d112 feat: add Traditional Chinese language support 2025-12-22 18:13:31 +08:00
bitcoinuser
2925c0c5f9 feat: update Portuguese translations for clarity (#697) 2025-12-22 14:52:55 +08:00
Max Blake
5705d8c9b3 feat: update pl.ts (#698)
Co-authored-by: Cody Tseng <codytseng98@gmail.com>
2025-12-22 14:52:16 +08:00
codytseng
944246b582 feat: ЁЯТи 2025-12-21 23:50:49 +08:00
codytseng
163f3212d8 chore: ЁЯОи 2025-12-21 21:11:51 +08:00
codytseng
1193c81c78 fix: ЁЯРЫ 2025-12-20 19:35:51 +08:00
codytseng
ddb88bf074 refactor: restructure the reply list 2025-12-20 19:22:27 +08:00
codytseng
079a2f90ef feat: add support for publishing highlights 2025-12-18 21:53:07 +08:00
148 changed files with 6392 additions and 3076 deletions

View File

@@ -1,12 +1,12 @@
# AGENTS.md # AGENTS.md
This document is designed to help AI Agents better understand and modify the Jumble project. This document is designed to help AI Agents better understand and modify the Smesh project.
## Project Overview ## Project Overview
Jumble is a user-friendly Nostr client for exploring relay feeds. Smesh is a user-friendly Nostr client for exploring relay feeds.
- **Project Name**: Jumble - **Project Name**: Smesh
- **Main Tech Stack**: React 18 + TypeScript + Vite - **Main Tech Stack**: React 18 + TypeScript + Vite
- **UI Framework**: Tailwind CSS + Radix UI - **UI Framework**: Tailwind CSS + Radix UI
- **State Management**: Jotai - **State Management**: Jotai
@@ -37,7 +37,7 @@ Jumble is a user-friendly Nostr client for exploring relay feeds.
### Project Structure ### Project Structure
``` ```
jumble/ smesh/
тФЬтФАтФА src/ тФЬтФАтФА src/
тФВ тФЬтФАтФА components/ # React components тФВ тФЬтФАтФА components/ # React components
тФВ тФВ тФЬтФАтФА ui/ # Base UI components (shadcn/ui style) тФВ тФВ тФЬтФАтФА ui/ # Base UI components (shadcn/ui style)
@@ -147,11 +147,11 @@ And some Providers are placed in `PageManager.tsx` because they need to use the
### Internationalization (i18n) ### Internationalization (i18n)
Jumble is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys. Smesh is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys.
- Translation files located in `src/i18n/locales/` - Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization - Using `react-i18next` for internationalization
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh - Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh, zh-TW
#### Adding New Language #### Adding New Language

786
DDD_ANALYSIS.md Normal file
View 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*

View File

@@ -1,32 +1,32 @@
<div align="center"> <div align="center">
<picture> <picture>
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" /> <img src="./resources/logo-light.svg" alt="Smesh Logo" width="400" />
</picture> </picture>
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p> <p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
</div> </div>
# Jumble # Smesh
A user-friendly Nostr client for exploring relay feeds A user-friendly Nostr client for exploring relay feeds
Experience Jumble at [https://jumble.social](https://jumble.social) Experience Smesh at [https://smesh.social](https://smesh.social)
## Forks ## Forks
> Some interesting forks of Jumble. > Some interesting forks of Smesh.
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://jumble.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk) - [https://fevela.me/](https://fevela.me/) - by [@daniele](https://smesh.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://jumble.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac) - [https://x21.com/](https://x21.com/) - by [@Karnage](https://smesh.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
- [https://jumble.imwald.eu/](https://jumble.imwald.eu/) Repo: [Silberengel/jumble](https://github.com/Silberengel/jumble) - by [@Silberengel](https://jumble.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z) - [https://smesh.imwald.eu/](https://smesh.imwald.eu/) Repo: [Silberengel/smesh](https://github.com/Silberengel/smesh) - by [@Silberengel](https://smesh.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
## Run Locally ## Run Locally
```bash ```bash
# Clone this repository # Clone this repository
git clone https://github.com/CodyTseng/jumble.git git clone https://git.mleku.dev/mleku/smesh.git
# Go into the repository # Go into the repository
cd jumble cd smesh
# Install dependencies # Install dependencies
npm install npm install
@@ -39,10 +39,10 @@ npm run dev
```bash ```bash
# Clone this repository # Clone this repository
git clone https://github.com/CodyTseng/jumble.git git clone https://git.mleku.dev/mleku/smesh.git
# Go into the repository # Go into the repository
cd jumble cd smesh
# Run the docker compose # Run the docker compose
docker compose up --build -d docker compose up --build -d
@@ -62,7 +62,7 @@ If you like this project, you can buy me a coffee :)
- **Lightning:** тЪбя╕П codytseng@getalby.com тЪбя╕П - **Lightning:** тЪбя╕П codytseng@getalby.com тЪбя╕П
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht - **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
- **Geyser:** https://geyser.fund/project/jumble - **Geyser:** https://geyser.fund/project/smesh
## License ## License

View File

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

View File

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

View File

@@ -4,30 +4,53 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Jumble</title> <title>Smesh</title>
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" /> <meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
<meta <meta
name="keywords" name="keywords"
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean" content="smesh, nostr, web, client, relay, feed, social, pwa, simple, clean"
/> />
<meta name="apple-mobile-web-app-title" content="Jumble" /> <meta name="apple-mobile-web-app-title" content="Smesh" />
<link rel="icon" href="/favicon.ico" sizes="48x48" /> <link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" /> <link rel="icon" href="/favicon.png" sizes="256x256" type="image/png" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta property="og:url" content="https://jumble.social" /> <meta property="og:url" content="https://smesh.mleku.dev" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Jumble" /> <meta property="og:title" content="Smesh" />
<meta <meta
property="og:description" property="og:description"
content="A user-friendly Nostr client for exploring relay feeds" content="A user-friendly Nostr client for exploring relay feeds"
/> />
<meta <meta
property="og:image" property="og:image"
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true" content="https://git.mleku.dev/mleku/smesh/raw/branch/master/resources/og-image.png"
/> />
<style>
/* Prevent flash - set background based on system preference before CSS loads */
html, body, #root { background-color: #FFFFFF; color: #171717; }
@media (prefers-color-scheme: dark) {
html, body, #root { background-color: #171717; color: #FAFAFA; }
}
html.light, html.light body, html.light #root { background-color: #FFFFFF !important; color: #171717 !important; }
html.dark, html.dark body, html.dark #root { background-color: #171717 !important; color: #FAFAFA !important; }
html.dark.pure-black, html.dark.pure-black body, html.dark.pure-black #root { background-color: #000000 !important; }
</style>
<script>
(function() {
var theme = localStorage.getItem('theme-setting');
if (theme === 'light') {
document.documentElement.classList.add('light');
} else if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else if (theme === 'pure-black') {
document.documentElement.classList.add('dark', 'pure-black');
}
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

1239
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "jumble", "name": "smesh",
"version": "0.1.0", "version": "0.1.0",
"description": "A user-friendly Nostr client for exploring relay feeds", "description": "A user-friendly Nostr client for exploring relay feeds",
"private": true, "private": true,
@@ -8,11 +8,12 @@
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/CodyTseng/jumble" "url": "git+https://git.mleku.dev/mleku/smesh"
}, },
"homepage": "https://github.com/CodyTseng/jumble", "homepage": "https://git.mleku.dev/mleku/smesh",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"dev:8080": "vite --host 0.0.0.0 --port 8080",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
@@ -25,6 +26,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0", "@getalby/bitcoin-connect-react": "^3.10.0",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
resources/smeshdark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
resources/smeshicondark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
resources/smeshlight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -15,10 +15,8 @@ import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider' import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider' import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider' import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider' import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider' import { ThemeProvider } from '@/providers/ThemeProvider'
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
import { UserTrustProvider } from '@/providers/UserTrustProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider'
import { ZapProvider } from '@/providers/ZapProvider' import { ZapProvider } from '@/providers/ZapProvider'
@@ -33,8 +31,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider> <DeletedEventProvider>
<NostrProvider> <NostrProvider>
<ZapProvider> <ZapProvider>
<TranslationServiceProvider> <FavoriteRelaysProvider>
<FavoriteRelaysProvider>
<FollowListProvider> <FollowListProvider>
<MuteListProvider> <MuteListProvider>
<UserTrustProvider> <UserTrustProvider>
@@ -43,14 +40,12 @@ export default function App(): JSX.Element {
<PinListProvider> <PinListProvider>
<PinnedUsersProvider> <PinnedUsersProvider>
<FeedProvider> <FeedProvider>
<ReplyProvider> <MediaUploadServiceProvider>
<MediaUploadServiceProvider> <KindFilterProvider>
<KindFilterProvider> <PageManager />
<PageManager /> <Toaster />
<Toaster /> </KindFilterProvider>
</KindFilterProvider> </MediaUploadServiceProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider> </FeedProvider>
</PinnedUsersProvider> </PinnedUsersProvider>
</PinListProvider> </PinListProvider>
@@ -59,8 +54,7 @@ export default function App(): JSX.Element {
</UserTrustProvider> </UserTrustProvider>
</MuteListProvider> </MuteListProvider>
</FollowListProvider> </FollowListProvider>
</FavoriteRelaysProvider> </FavoriteRelaysProvider>
</TranslationServiceProvider>
</ZapProvider> </ZapProvider>
</NostrProvider> </NostrProvider>
</DeletedEventProvider> </DeletedEventProvider>

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
src/assets/smeshdark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
src/assets/smeshlight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,7 +11,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
const content = ( const content = (
<> <>
<div className="text-xl font-semibold">Jumble</div> <div className="text-xl font-semibold">Smesh</div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
A user-friendly Nostr client for exploring relay feeds A user-friendly Nostr client for exploring relay feeds
</div> </div>
@@ -21,15 +21,15 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
<div> <div>
Source code:{' '} Source code:{' '}
<a <a
href="https://github.com/CodyTseng/jumble" href="https://git.mleku.dev/mleku/smesh"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
GitHub Git
</a> </a>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
If you like Jumble, please consider giving it a star тнР If you like Smesh, please consider giving it a star тнР
</div> </div>
</div> </div>
</> </>

View File

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

View File

@@ -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)
}

View File

@@ -2,17 +2,15 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { isDevEnv } from '@/lib/utils' import { isDevEnv } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList' import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin' import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin' import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin' import PrivateKeyLogin from './PrivateKeyLogin'
import Signup from './Signup'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | 'signup' | null
export default function AccountManager({ close }: { close?: () => void }) { export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null) const [page, setPage] = useState<TAccountManagerPage>(null)
@@ -23,10 +21,10 @@ export default function AccountManager({ close }: { close?: () => void }) {
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? ( ) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? ( ) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} /> <NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'signup' ? (
<Signup back={() => setPage(null)} onSignupSuccess={() => close?.()} />
) : ( ) : (
<AccountManagerNav setPage={setPage} close={close} /> <AccountManagerNav setPage={setPage} close={close} />
)} )}
@@ -41,9 +39,8 @@ function AccountManagerNav({
setPage: (page: TAccountManagerPage) => void setPage: (page: TAccountManagerPage) => void
close?: () => void close?: () => void
}) { }) {
const { t, i18n } = useTranslation() const { t } = useTranslation()
const { themeSetting } = useTheme() const { nip07Login, accounts } = useNostr()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
return ( return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8"> <div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@@ -75,38 +72,8 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold"> <div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")} {t("Don't have an account yet?")}
</div> </div>
<Button <Button onClick={() => setPage('signup')} className="w-full mt-4">
onClick={() => { {t('Create New Account')}
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
</Button> </Button>
</div> </div>
{accounts.length > 0 && ( {accounts.length > 0 && (

View File

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

View File

@@ -1,4 +1,3 @@
import { useTranslatedEvent } from '@/hooks'
import { import {
EmbeddedEmojiParser, EmbeddedEmojiParser,
EmbeddedEventParser, EmbeddedEventParser,
@@ -15,7 +14,7 @@ import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo, useRef, useState } from 'react'
import { import {
EmbeddedHashtag, EmbeddedHashtag,
EmbeddedLNInvoice, EmbeddedLNInvoice,
@@ -25,8 +24,10 @@ import {
} from '../Embedded' } from '../Embedded'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import HighlightButton from '../HighlightButton'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import PostEditor from '../PostEditor'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost' import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
@@ -35,16 +36,20 @@ export default function Content({
event, event,
content, content,
className, className,
mustLoadMedia mustLoadMedia,
enableHighlight = false
}: { }: {
event?: Event event?: Event
content?: string content?: string
className?: string className?: string
mustLoadMedia?: boolean mustLoadMedia?: boolean
enableHighlight?: boolean
}) { }) {
const translatedEvent = useTranslatedEvent(event?.id) const contentRef = useRef<HTMLDivElement>(null)
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => { const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content const _content = event?.content ?? content
if (!_content) return {} if (!_content) return {}
const nodes = parseContent(_content, [ const nodes = parseContent(_content, [
@@ -89,87 +94,105 @@ export default function Content({
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl } return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content]) }, [event, content])
if (!nodes || nodes.length === 0) { if (!nodes || nodes.length === 0) {
return null return null
} }
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
let imageIndex = 0 let imageIndex = 0
return ( return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}> <>
{nodes.map((node, index) => { <div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
if (node.type === 'text') { {nodes.map((node, index) => {
return node.data if (node.type === 'text') {
} return node.data
if (node.type === 'image' || node.type === 'images') { }
const start = imageIndex if (node.type === 'image' || node.type === 'images') {
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1) const start = imageIndex
imageIndex = end const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
return ( imageIndex = end
<ImageGallery return (
className="mt-2" <ImageGallery
key={index} className="mt-2"
images={allImages} key={index}
start={start} images={allImages}
end={end} start={start}
mustLoad={mustLoadMedia} end={end}
/> mustLoad={mustLoadMedia}
) />
} )
if (node.type === 'media') { }
return ( if (node.type === 'media') {
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} /> return (
) <MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
} )
if (node.type === 'url') { }
return <ExternalLink url={node.data} key={index} /> if (node.type === 'url') {
} return <ExternalLink url={node.data} key={index} />
if (node.type === 'invoice') { }
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" /> if (node.type === 'invoice') {
} return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
if (node.type === 'websocket-url') { }
return <EmbeddedWebsocketUrl url={node.data} key={index} /> if (node.type === 'websocket-url') {
} return <EmbeddedWebsocketUrl url={node.data} key={index} />
if (node.type === 'event') { }
const id = node.data.split(':')[1] if (node.type === 'event') {
return <EmbeddedNote key={index} noteId={id} className="mt-2" /> const id = node.data.split(':')[1]
} return <EmbeddedNote key={index} noteId={id} className="mt-2" />
if (node.type === 'mention') { }
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} /> if (node.type === 'mention') {
} return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
if (node.type === 'hashtag') { }
return <EmbeddedHashtag hashtag={node.data} key={index} /> if (node.type === 'hashtag') {
} return <EmbeddedHashtag hashtag={node.data} key={index} />
if (node.type === 'emoji') { }
const shortcode = node.data.split(':')[1] if (node.type === 'emoji') {
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) const shortcode = node.data.split(':')[1]
if (!emoji) return node.data const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} /> if (!emoji) return node.data
} return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
if (node.type === 'youtube') { }
return ( if (node.type === 'youtube') {
<YoutubeEmbeddedPlayer return (
key={index} <YoutubeEmbeddedPlayer
url={node.data} key={index}
className="mt-2" url={node.data}
mustLoad={mustLoadMedia} className="mt-2"
/> mustLoad={mustLoadMedia}
) />
} )
if (node.type === 'x-post') { }
return ( if (node.type === 'x-post') {
<XEmbeddedPost return (
key={index} <XEmbeddedPost
url={node.data} key={index}
className="mt-2" url={node.data}
mustLoad={mustLoadMedia} className="mt-2"
/> mustLoad={mustLoadMedia}
) />
} )
return null }
})} return null
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />} })}
</div> {lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
{enableHighlight && (
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
)}
{enableHighlight && (
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
)}
</>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { JUMBLE_PUBKEY } from '@/constants' import { SMESH_PUBKEY } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
import PlatinumSponsors from './PlatinumSponsors'
import RecentSupporters from './RecentSupporters' import RecentSupporters from './RecentSupporters'
export default function Donation({ className }: { className?: string }) { export default function Donation({ className }: { className?: string }) {
@@ -14,9 +13,9 @@ export default function Donation({ className }: { className?: string }) {
return ( return (
<div className={cn('p-4 border rounded-lg space-y-4', className)}> <div className={cn('p-4 border rounded-lg space-y-4', className)}>
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div> <div className="text-center font-semibold">{t('Enjoying Smesh?')}</div>
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
{t('Your donation helps me maintain Jumble and make it better! ЁЯШК')} {t('Your donation helps me maintain Smesh and make it better! ЁЯШК')}
</div> </div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[ {[
@@ -40,12 +39,11 @@ export default function Donation({ className }: { className?: string }) {
) )
})} })}
</div> </div>
<PlatinumSponsors />
<RecentSupporters /> <RecentSupporters />
<ZapDialog <ZapDialog
open={open} open={open}
setOpen={setOpen} setOpen={setOpen}
pubkey={JUMBLE_PUBKEY} pubkey={SMESH_PUBKEY}
defaultAmount={donationAmount} defaultAmount={donationAmount}
/> />
</div> </div>

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -33,16 +33,16 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
<p className="text-lg text-center max-w-md"> <p className="text-lg text-center max-w-md">
Sorry for the inconvenience. If you don't mind helping, you can{' '} Sorry for the inconvenience. If you don't mind helping, you can{' '}
<a <a
href="https://github.com/CodyTseng/jumble/issues/new" href="https://git.mleku.dev/mleku/smesh/issues/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary underline" className="text-primary underline"
> >
submit an issue on GitHub submit an issue
</a>{' '} </a>{' '}
with the error details, or{' '} with the error details, or{' '}
<a <a
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl" href="https://smesh.mleku.dev/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary underline" className="text-primary underline"

View File

@@ -1,22 +1,30 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { recommendRelaysByLanguage } from '@/lib/relay'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TAwesomeRelayCollection } from '@/types' import { TAwesomeRelayCollection } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { cn } from '@/lib/utils'
export default function Explore() { export default function Explore() {
const { t, i18n } = useTranslation()
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null) const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
const recommendedRelays = useMemo(() => {
const lang = i18n.language
const relays = recommendRelaysByLanguage(lang)
return relays
}, [i18n.language])
useEffect(() => { useEffect(() => {
relayInfoService.getAwesomeRelayCollections().then(setCollections) relayInfoService.getAwesomeRelayCollections().then(setCollections)
}, []) }, [])
if (!collections) { if (!collections && recommendedRelays.length === 0) {
return ( return (
<div> <div>
<div className="p-4 max-md:border-b"> <div className="p-4 max-md:border-b">
@@ -31,9 +39,19 @@ export default function Explore() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{collections.map((collection) => ( {recommendedRelays.length > 0 && (
<RelayCollection key={collection.id} collection={collection} /> <RelayCollection
))} collection={{
id: 'recommended',
name: t('Recommended'),
relays: recommendedRelays
}}
/>
)}
{collections &&
collections.map((collection) => (
<RelayCollection key={collection.id} collection={collection} />
))}
</div> </div>
) )
} }

View File

@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
export default function ExternalContentInteractions({ export default function ExternalContentInteractions({
pageIndex,
externalContent externalContent
}: { }: {
pageIndex?: number
externalContent: string externalContent: string
}) { }) {
const [type, setType] = useState<TTabValue>('replies') const [type, setType] = useState<TTabValue>('replies')
let list let list
switch (type) { switch (type) {
case 'replies': case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={externalContent} /> list = <ReplyNoteList stuff={externalContent} />
break break
case 'reactions': case 'reactions':
list = <ReactionList stuff={externalContent} /> list = <ReactionList stuff={externalContent} />

View File

@@ -155,7 +155,7 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
const copyShareLink = () => { const copyShareLink = () => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}` `https://smesh.mleku.dev/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
) )
} }

View File

@@ -60,39 +60,41 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
} }
return isFollowing ? ( return isFollowing ? (
<AlertDialog> <div onClick={(e) => e.stopPropagation()}>
<AlertDialogTrigger asChild> <AlertDialog>
<Button <AlertDialogTrigger asChild>
className="rounded-full min-w-28" <Button
variant={hover ? 'destructive' : 'secondary'} className="rounded-full min-w-28"
disabled={updating} variant={hover ? 'destructive' : 'secondary'}
onMouseEnter={() => setHover(true)} disabled={updating}
onMouseLeave={() => setHover(false)} onMouseEnter={() => setHover(true)}
> onMouseLeave={() => setHover(false)}
{updating ? ( >
<Loader className="animate-spin" /> {updating ? (
) : hover ? ( <Loader className="animate-spin" />
t('Unfollow') ) : hover ? (
) : ( t('Unfollow')
t('buttonFollowing') ) : (
)} t('buttonFollowing')
</Button> )}
</AlertDialogTrigger> </Button>
<AlertDialogContent> </AlertDialogTrigger>
<AlertDialogHeader> <AlertDialogContent>
<AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle> <AlertDialogHeader>
<AlertDialogDescription> <AlertDialogTitle>{t('Unfollow')}?</AlertDialogTitle>
{t('Are you sure you want to unfollow this user?')} <AlertDialogDescription>
</AlertDialogDescription> {t('Are you sure you want to unfollow this user?')}
</AlertDialogHeader> </AlertDialogDescription>
<AlertDialogFooter> </AlertDialogHeader>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel> <AlertDialogFooter>
<AlertDialogAction onClick={handleUnfollow} variant="destructive"> <AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
{t('Unfollow')} <AlertDialogAction onClick={handleUnfollow} variant="destructive">
</AlertDialogAction> {t('Unfollow')}
</AlertDialogFooter> </AlertDialogAction>
</AlertDialogContent> </AlertDialogFooter>
</AlertDialog> </AlertDialogContent>
</AlertDialog>
</div>
) : ( ) : (
<Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}> <Button className="rounded-full min-w-28" onClick={handleFollow} disabled={updating}>
{updating ? <Loader className="animate-spin" /> : t('Follow')} {updating ? <Loader className="animate-spin" /> : t('Follow')}

View 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>
)
}

View File

@@ -73,13 +73,13 @@ export default function Image({
} }
return ( return (
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}> <div className={cn('relative overflow-hidden rounded-xl', classNames.wrapper)} {...props}>
{/* Spacer: transparent image to maintain dimensions when image is loading */} {/* Spacer: transparent image to maintain dimensions when image is loading */}
{isLoading && dim?.width && dim?.height && ( {isLoading && dim?.width && dim?.height && (
<img <img
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`} src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
className={cn( className={cn(
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full', 'object-cover transition-opacity pointer-events-none w-full h-full',
className className
)} )}
alt="" alt=""
@@ -91,7 +91,7 @@ export default function Image({
<ThumbHashPlaceholder <ThumbHashPlaceholder
thumbHash={thumbHash} thumbHash={thumbHash}
className={cn( className={cn(
'w-full h-full transition-opacity rounded-xl', 'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0' isLoading ? 'opacity-100' : 'opacity-0'
)} )}
/> />
@@ -99,14 +99,14 @@ export default function Image({
<BlurHashCanvas <BlurHashCanvas
blurHash={blurHash} blurHash={blurHash}
className={cn( className={cn(
'w-full h-full transition-opacity rounded-xl', 'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0' isLoading ? 'opacity-100' : 'opacity-0'
)} )}
/> />
) : ( ) : (
<Skeleton <Skeleton
className={cn( className={cn(
'w-full h-full transition-opacity rounded-xl', 'w-full h-full transition-opacity',
isLoading ? 'opacity-100' : 'opacity-0', isLoading ? 'opacity-100' : 'opacity-0',
classNames.skeleton classNames.skeleton
)} )}
@@ -124,7 +124,7 @@ export default function Image({
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
className={cn( className={cn(
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full', 'object-cover transition-opacity pointer-events-none w-full h-full',
isLoading ? 'opacity-0 absolute inset-0' : '', isLoading ? 'opacity-0 absolute inset-0' : '',
className className
)} )}
@@ -137,7 +137,7 @@ export default function Image({
alt={alt} alt={alt}
decoding="async" decoding="async"
loading="lazy" loading="lazy"
className={cn('object-cover rounded-xl w-full h-full transition-opacity', className)} className={cn('object-cover w-full h-full transition-opacity', className)}
/> />
) : ( ) : (
<div <div

View File

@@ -94,9 +94,9 @@ export default function ImageGallery({
<ImageWithLightbox <ImageWithLightbox
key={i} key={i}
image={image} image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain border" className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{ classNames={{
wrapper: cn('w-fit max-w-full', className) wrapper: cn('w-fit max-w-full border', className)
}} }}
/> />
)) ))
@@ -107,10 +107,10 @@ export default function ImageGallery({
imageContent = ( imageContent = (
<Image <Image
key={0} key={0}
className="max-h-[80vh] sm:max-h-[50vh] object-contain border" className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{ classNames={{
errorPlaceholder: 'aspect-square h-[30vh]', errorPlaceholder: 'aspect-square h-[30vh]',
wrapper: 'cursor-zoom-in' wrapper: 'cursor-zoom-in border'
}} }}
image={displayImages[0]} image={displayImages[0]}
onClick={(e) => handlePhotoClick(e, 0)} onClick={(e) => handlePhotoClick(e, 0)}
@@ -122,8 +122,8 @@ export default function ImageGallery({
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className="aspect-square w-full border" className="aspect-square w-full"
classNames={{ wrapper: 'cursor-zoom-in' }} classNames={{ wrapper: 'cursor-zoom-in border' }}
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />
@@ -136,8 +136,8 @@ export default function ImageGallery({
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
<Image <Image
key={i} key={i}
className="aspect-square w-full border" className="aspect-square w-full"
classNames={{ wrapper: 'cursor-zoom-in' }} classNames={{ wrapper: 'cursor-zoom-in border' }}
image={image} image={image}
onClick={(e) => handlePhotoClick(e, i)} onClick={(e) => handlePhotoClick(e, i)}
/> />

View File

@@ -67,7 +67,7 @@ export default function ImageWithLightbox({
key={0} key={0}
className={className} className={className}
classNames={{ classNames={{
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper), wrapper: cn('border cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]', errorPlaceholder: 'aspect-square h-[30vh]',
skeleton: classNames.skeleton skeleton: classNames.skeleton
}} }}

View 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>
)
}

View File

@@ -1,7 +1,7 @@
import { TMailboxRelay } from '@/types' import { TMailboxRelay } from '@/types'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard' import InfoCard from '../InfoCard'
export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) { export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -19,7 +19,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
} }
return ( return (
<AlertCard <InfoCard
variant="alert"
title={showReadWarning ? t('Too many read relays') : t('Too many write relays')} title={showReadWarning ? t('Too many read relays') : t('Too many write relays')}
content={ content={
showReadWarning showReadWarning

View File

@@ -15,13 +15,15 @@ export default function NormalFeed({
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false, showRelayCloseReason = false,
disable24hMode = false disable24hMode = false,
onRefresh
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
isMainFeed?: boolean isMainFeed?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
disable24hMode?: boolean disable24hMode?: boolean
onRefresh?: () => void
}) { }) {
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
@@ -65,6 +67,10 @@ export default function NormalFeed({
{!supportTouch && ( {!supportTouch && (
<RefreshButton <RefreshButton
onClick={() => { onClick={() => {
if (onRefresh) {
onRefresh()
return
}
if (listMode === '24h') { if (listMode === '24h') {
userAggregationListRef.current?.refresh() userAggregationListRef.current?.refresh()
} else { } else {

View File

@@ -26,7 +26,7 @@ export default function FollowPack({ event, className }: { event: Event; classNa
{image && ( {image && (
<Image <Image
image={{ url: image, pubkey: event.pubkey }} image={{ url: image, pubkey: event.pubkey }}
className="w-24 h-20 object-cover rounded-lg" className="w-24 h-20 object-cover"
classNames={{ classNames={{
wrapper: 'w-24 h-20 flex-shrink-0', wrapper: 'w-24 h-20 flex-shrink-0',
errorPlaceholder: 'w-24 h-20' errorPlaceholder: 'w-24 h-20'

View File

@@ -1,4 +1,4 @@
import { useFetchEvent, useTranslatedEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { isValidPubkey } from '@/lib/pubkey' import { isValidPubkey } from '@/lib/pubkey'
@@ -14,19 +14,23 @@ import ExternalLink from '../ExternalLink'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
export default function Highlight({ event, className }: { event: Event; className?: string }) { export default function Highlight({ event, className }: { event: Event; className?: string }) {
const translatedEvent = useTranslatedEvent(event.id)
const comment = useMemo( const comment = useMemo(
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1], () => event.tags.find((tag) => tag[0] === 'comment')?.[1],
[event, translatedEvent] [event]
) )
return ( return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}> <div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
{comment && <Content event={createFakeEvent({ content: comment })} />} {comment && <Content event={createFakeEvent({ content: comment, tags: event.tags })} />}
<div className="flex gap-4"> <div className="flex gap-4">
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" /> <div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
<div className="italic whitespace-pre-line"> <div
{translatedEvent?.content ?? event.content} className="italic whitespace-pre-line"
style={{
overflowWrap: 'anywhere'
}}
>
{event.content}
</div> </div>
</div> </div>
<HighlightSource event={event} /> <HighlightSource event={event} />

View File

@@ -1,10 +1,12 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox' import ImageWithLightbox from '@/components/ImageWithLightbox'
import HighlightButton from '@/components/HighlightButton'
import PostEditor from '@/components/PostEditor'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link' import { toNote, toNoteList, toProfile } from '@/lib/link'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo, useRef, useState } from 'react'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import NostrNode from './NostrNode' import NostrNode from './NostrNode'
@@ -20,6 +22,14 @@ export default function LongFormArticle({
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
const components = useMemo( const components = useMemo(
() => () =>
@@ -74,54 +84,64 @@ export default function LongFormArticle({
/> />
) )
}) as Components, }) as Components,
[] [event.pubkey]
) )
return ( return (
<div <>
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`} <div
> ref={contentRef}
<h1 className="break-words">{metadata.title}</h1> className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
{metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-[3/1] object-cover my-0"
/>
)}
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
> >
{event.content} <h1 className="break-words">{metadata.title}</h1>
</Markdown> {metadata.summary && (
{metadata.tags.length > 0 && ( <blockquote>
<div className="flex gap-2 flex-wrap pb-2"> <p className="break-words">{metadata.summary}</p>
{metadata.tags.map((tag) => ( </blockquote>
<div )}
key={tag} {metadata.image && (
title={tag} <ImageWithLightbox
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground" image={{ url: metadata.image, pubkey: event.pubkey }}
onClick={(e) => { className="w-full aspect-[3/1] object-cover my-0"
e.stopPropagation() />
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) )}
}} <Markdown
> remarkPlugins={[remarkGfm, remarkNostr]}
#<span className="truncate">{tag}</span> urlTransform={(url) => {
</div> if (url.startsWith('nostr:')) {
))} return url.slice(6) // Remove 'nostr:' prefix for rendering
</div> }
)} return url
</div> }}
components={components}
>
{event.content}
</Markdown>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
</div>
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
</>
) )
} }

View File

@@ -67,7 +67,7 @@ export default function LongFormArticlePreview({
{metadata.image && autoLoadMedia && ( {metadata.image && autoLoadMedia && (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44" className="aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
hideIfError hideIfError
/> />
)} )}

View File

@@ -1,6 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { POLL_TYPE } from '@/constants' import { POLL_TYPE } from '@/constants'
import { useTranslatedEvent } from '@/hooks'
import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { useFetchPollResults } from '@/hooks/useFetchPollResults'
import { createPollResponseDraftEvent } from '@/lib/draft-event' import { createPollResponseDraftEvent } from '@/lib/draft-event'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
@@ -17,16 +16,12 @@ import { toast } from 'sonner'
export default function Poll({ event, className }: { event: Event; className?: string }) { export default function Poll({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event.id)
const { pubkey, publish, startLogin } = useNostr() const { pubkey, publish, startLogin } = useNostr()
const [isVoting, setIsVoting] = useState(false) const [isVoting, setIsVoting] = useState(false)
const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([]) const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
const pollResults = useFetchPollResults(event.id) const pollResults = useFetchPollResults(event.id)
const [isLoadingResults, setIsLoadingResults] = useState(false) const [isLoadingResults, setIsLoadingResults] = useState(false)
const poll = useMemo( const poll = useMemo(() => getPollMetadataFromEvent(event), [event])
() => getPollMetadataFromEvent(translatedEvent ?? event),
[event, translatedEvent]
)
const votedOptionIds = useMemo(() => { const votedOptionIds = useMemo(() => {
if (!pollResults || !pubkey) return [] if (!pollResults || !pubkey) return []
return Object.entries(pollResults.results) return Object.entries(pollResults.results)

View File

@@ -15,7 +15,6 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
import TrustScoreBadge from '../TrustScoreBadge' import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
@@ -117,7 +116,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.FOLLOW_PACK) { } else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPack className="mt-2" event={event} /> content = <FollowPack className="mt-2" event={event} />
} else { } else {
content = <Content className="mt-2" event={event} /> content = <Content className="mt-2" event={event} enableHighlight />
} }
return ( return (
@@ -146,12 +145,9 @@ export default function Note({
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center"> {size === 'normal' && (
<TranslateButton event={event} className={size === 'normal' ? '' : 'pr-0'} /> <NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
{size === 'normal' && ( )}
<NoteOptions event={event} className="py-1 shrink-0 [&_svg]:size-5" />
)}
</div>
</div> </div>
{!hideParentNotePreview && ( {!hideParentNotePreview && (
<ParentNotePreview <ParentNotePreview

View File

@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
import ZapList from '../ZapList' import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({ export default function NoteInteractions({ event }: { event: Event }) {
pageIndex,
event
}: {
pageIndex?: number
event: Event
}) {
const [type, setType] = useState<TTabValue>('replies') const [type, setType] = useState<TTabValue>('replies')
let list let list
switch (type) { switch (type) {
case 'replies': case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={event} /> list = <ReplyNoteList stuff={event} />
break break
case 'quotes': case 'quotes':
list = <QuoteList stuff={event} /> list = <QuoteList stuff={event} />

View File

@@ -1,5 +1,6 @@
import NewNotesButton from '@/components/NewNotesButton' import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event' import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
@@ -7,9 +8,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import threadService from '@/services/thread.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@@ -76,11 +77,9 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [initialLoading, setInitialLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [filtering, setFiltering] = useState(false) const [filtering, setFiltering] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [filteredNotes, setFilteredNotes] = useState< const [filteredNotes, setFilteredNotes] = useState<
@@ -88,9 +87,7 @@ const NoteList = forwardRef<
>([]) >([])
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([]) const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null) const topRef = useRef<HTMLDivElement | null>(null)
const shouldHideEvent = useCallback( const shouldHideEvent = useCallback(
@@ -218,10 +215,6 @@ const NoteList = forwardRef<
processEvents().finally(() => setFiltering(false)) processEvents().finally(() => setFiltering(false))
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam]) }, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
const slicedNotes = useMemo(() => {
return filteredNotes.slice(0, showCount)
}, [filteredNotes, showCount])
useEffect(() => { useEffect(() => {
const processNewEvents = async () => { const processNewEvents = async () => {
const keySet = new Set<string>() const keySet = new Set<string>()
@@ -273,14 +266,11 @@ const NoteList = forwardRef<
if (!subRequests.length) return if (!subRequests.length) return
async function init() { async function init() {
setLoading(true) setInitialLoading(true)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true)
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
setLoading(false)
setHasMore(false)
return () => {} return () => {}
} }
@@ -305,12 +295,9 @@ const NoteList = forwardRef<
if (events.length > 0) { if (events.length > 0) {
setEvents(events) setEvents(events)
} }
if (areAlgoRelays) {
setHasMore(false)
}
if (eosed) { if (eosed) {
setLoading(false) threadService.addRepliesToThread(events)
addReplies(events) setInitialLoading(false)
} }
}, },
onNew: (event) => { onNew: (event) => {
@@ -323,7 +310,7 @@ const NoteList = forwardRef<
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
) )
} }
addReplies([event]) threadService.addRepliesToThread([event])
}, },
onClose: (url, reason) => { onClose: (url, reason) => {
if (!showRelayCloseReason) return if (!showRelayCloseReason) return
@@ -358,55 +345,26 @@ const NoteList = forwardRef<
} }
}, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)]) }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
useEffect(() => { const handleLoadMore = useCallback(async () => {
const options = { if (!timelineKey || areAlgoRelays) return false
root: null, const newEvents = await client.loadMoreTimeline(
rootMargin: '10px', timelineKey,
threshold: 0.1 events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT
)
if (newEvents.length === 0) {
return false
} }
setEvents((oldEvents) => [...oldEvents, ...newEvents])
return true
}, [timelineKey, events, areAlgoRelays])
const loadMore = async () => { const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
if (showCount < events.length) { items: filteredNotes,
setShowCount((prev) => prev + SHOW_COUNT) showCount: SHOW_COUNT,
// preload more onLoadMore: handleLoadMore,
if (events.length - showCount > LIMIT / 2) { initialLoading
return })
}
}
if (!timelineKey || loading || !hasMore) return
setLoading(true)
const newEvents = await client.loadMoreTimeline(
timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT
)
setLoading(false)
if (newEvents.length === 0) {
setHasMore(false)
return
}
setEvents((oldEvents) => [...oldEvents, ...newEvents])
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [loading, hasMore, events, showCount, timelineKey])
const showNewEvents = () => { const showNewEvents = () => {
setEvents((oldEvents) => [...newEvents, ...oldEvents]) setEvents((oldEvents) => [...newEvents, ...oldEvents])
@@ -419,7 +377,7 @@ const NoteList = forwardRef<
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)} {pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
{slicedNotes.map(({ key, event, reposters }) => ( {visibleItems.map(({ key, event, reposters }) => (
<NoteCard <NoteCard
key={key} key={key}
className="w-full" className="w-full"
@@ -428,10 +386,9 @@ const NoteList = forwardRef<
reposters={reposters} reposters={reposters}
/> />
))} ))}
{hasMore || showCount < events.length || loading || filtering ? ( <div ref={bottomRef} />
<div ref={bottomRef}> {shouldShowLoadingIndicator || filtering || initialLoading ? (
<NoteCardLoadingSkeleton /> <NoteCardLoadingSkeleton />
</div>
) : events.length ? ( ) : events.length ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : ( ) : (

View File

@@ -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}
/>
)
}

View File

@@ -40,8 +40,8 @@ export function ReactionNotification({
<Image <Image
image={{ url: emojiUrl, pubkey: notification.pubkey }} image={{ url: emojiUrl, pubkey: notification.pubkey }}
alt={emojiName} alt={emojiName}
className="w-6 h-6 rounded-md" className="w-6 h-6"
classNames={{ errorPlaceholder: 'bg-transparent' }} classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
errorPlaceholder={<Heart size={24} className="text-red-400" />} errorPlaceholder={<Heart size={24} className="text-red-400" />}
/> />
) )

View File

@@ -6,6 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { HighlightNotification } from './HighlightNotification'
import { MentionNotification } from './MentionNotification' import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification' import { PollResponseNotification } from './PollResponseNotification'
import { ReactionNotification } from './ReactionNotification' import { ReactionNotification } from './ReactionNotification'
@@ -60,5 +61,8 @@ export function NotificationItem({
if (notification.kind === ExtendedKind.POLL_RESPONSE) { if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} isNew={isNew} /> return <PollResponseNotification notification={notification} isNew={isNew} />
} }
if (notification.kind === kinds.Highlights) {
return <HighlightNotification notification={notification} isNew={isNew} />
}
return null return null
} }

View File

@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider' import { useNotification } from '@/providers/NotificationProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import stuffStatsService from '@/services/stuff-stats.service' import stuffStatsService from '@/services/stuff-stats.service'
import threadService from '@/services/thread.service'
import { TNotificationType } from '@/types' import { TNotificationType } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { NostrEvent, kinds, matchFilter } from 'nostr-tools' import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { getNotificationsSeenAt } = useNotification() const { getNotificationsSeenAt } = useNotification()
const { notificationListStyle } = useUserPreferences() const { notificationListStyle } = useUserPreferences()
const { addReplies } = useReply()
const [notificationType, setNotificationType] = useState<TNotificationType>('all') const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0) const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
@@ -55,6 +54,7 @@ const NotificationList = forwardRef((_, ref) => {
case 'mentions': case 'mentions':
return [ return [
kinds.ShortTextNote, kinds.ShortTextNote,
kinds.Highlights,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL ExtendedKind.POLL
@@ -70,6 +70,7 @@ const NotificationList = forwardRef((_, ref) => {
kinds.GenericRepost, kinds.GenericRepost,
kinds.Reaction, kinds.Reaction,
kinds.Zap, kinds.Zap,
kinds.Highlights,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE, ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
@@ -141,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
if (eosed) { if (eosed) {
setLoading(false) setLoading(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
addReplies(events) threadService.addRepliesToThread(events)
stuffStatsService.updateStuffStatsByEvents(events) stuffStatsService.updateStuffStatsByEvents(events)
} }
}, },
onNew: (event) => { onNew: (event) => {
handleNewEvent(event) handleNewEvent(event)
addReplies([event]) threadService.addRepliesToThread([event])
} }
} }
) )

View File

@@ -8,7 +8,7 @@ import dayjs from 'dayjs'
import { Eraser, X } from 'lucide-react' import { Eraser, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AlertCard from '../AlertCard' import InfoCard from '../InfoCard'
export default function PollEditor({ export default function PollEditor({
pollCreateData, pollCreateData,
@@ -125,7 +125,8 @@ export default function PollEditor({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<AlertCard <InfoCard
variant="alert"
title={t('This is a poll note.')} title={t('This is a poll note.')}
content={t( content={t(
'Unlike regular notes, polls are not widely supported and may not display on other clients.' 'Unlike regular notes, polls are not widely supported and may not display on other clients.'

View File

@@ -1,16 +1,18 @@
import Note from '@/components/Note' import Note from '@/components/Note'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { BIG_RELAY_URLS } from '@/constants'
import { import {
createCommentDraftEvent, createCommentDraftEvent,
createHighlightDraftEvent,
createPollDraftEvent, createPollDraftEvent,
createShortTextNoteDraftEvent, createShortTextNoteDraftEvent,
deleteDraftEventCache deleteDraftEventCache
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import threadService from '@/services/thread.service'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react' import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@@ -24,22 +26,22 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector' import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import Uploader from './Uploader' import Uploader from './Uploader'
import { BIG_RELAY_URLS } from '@/constants'
export default function PostContent({ export default function PostContent({
defaultContent = '', defaultContent = '',
parentStuff, parentStuff,
close, close,
openFrom openFrom,
highlightedText
}: { }: {
defaultContent?: string defaultContent?: string
parentStuff?: Event | string parentStuff?: Event | string
close: () => void close: () => void
openFrom?: string[] openFrom?: string[]
highlightedText?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { addReplies } = useReply()
const [text, setText] = useState('') const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null) const textareaRef = useRef<TPostTextareaHandle>(null)
const [posting, setPosting] = useState(false) const [posting, setPosting] = useState(false)
@@ -68,7 +70,7 @@ export default function PostContent({
const canPost = useMemo(() => { const canPost = useMemo(() => {
return ( return (
!!pubkey && !!pubkey &&
!!text && (!!text || !!highlightedText) &&
!posting && !posting &&
!uploadProgresses.length && !uploadProgresses.length &&
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
@@ -77,6 +79,7 @@ export default function PostContent({
}, [ }, [
pubkey, pubkey,
text, text,
highlightedText,
posting, posting,
uploadProgresses, uploadProgresses,
isPoll, isPoll,
@@ -123,30 +126,23 @@ export default function PostContent({
const post = async (e?: React.MouseEvent) => { const post = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
checkLogin(async () => { checkLogin(async () => {
if (!canPost || postingRef.current) return if (!canPost || !pubkey || postingRef.current) return
postingRef.current = true postingRef.current = true
setPosting(true) setPosting(true)
try { try {
const draftEvent = const draftEvent = await createDraftEvent({
parentStuff && parentStuff,
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote) highlightedText,
? await createCommentDraftEvent(text, parentStuff, mentions, { text,
addClientTag, mentions,
protectedEvent: isProtectedEvent, isPoll,
isNsfw pollCreateData,
}) pubkey,
: isPoll addClientTag,
? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, { isProtectedEvent,
addClientTag, isNsfw
isNsfw })
})
: await createShortTextNoteDraftEvent(text, mentions, {
parentEvent,
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
const _additionalRelayUrls = [...additionalRelayUrls] const _additionalRelayUrls = [...additionalRelayUrls]
if (parentStuff && typeof parentStuff === 'string') { if (parentStuff && typeof parentStuff === 'string') {
@@ -160,7 +156,7 @@ export default function PostContent({
}) })
postEditorCache.clearPostCache({ defaultContent, parentStuff }) postEditorCache.clearPostCache({ defaultContent, parentStuff })
deleteDraftEventCache(draftEvent) deleteDraftEventCache(draftEvent)
addReplies([newEvent]) threadService.addRepliesToThread([newEvent])
toast.success(t('Post successful'), { duration: 2000 }) toast.success(t('Post successful'), { duration: 2000 })
close() close()
} catch (error) { } catch (error) {
@@ -205,7 +201,14 @@ export default function PostContent({
{parentEvent && ( {parentEvent && (
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40"> <ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
<div className="p-2 sm:p-3 pointer-events-none"> <div className="p-2 sm:p-3 pointer-events-none">
<Note size="small" event={parentEvent} hideParentNotePreview /> {highlightedText ? (
<div className="flex gap-4">
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
<div className="italic whitespace-pre-line">{highlightedText}</div>
</div>
) : (
<Note size="small" event={parentEvent} hideParentNotePreview />
)}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@@ -220,6 +223,7 @@ export default function PostContent({
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress} onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined}
/> />
{isPoll && ( {isPoll && (
<PollEditor <PollEditor
@@ -332,7 +336,7 @@ export default function PostContent({
</Button> </Button>
<Button type="submit" disabled={!canPost} onClick={post}> <Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && <LoaderCircle className="animate-spin" />}
{parentStuff ? t('Reply') : t('Post')} {parentStuff ? (highlightedText ? t('Publish Highlight') : t('Reply')) : t('Post')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -366,3 +370,62 @@ export default function PostContent({
</div> </div>
) )
} }
async function createDraftEvent({
parentStuff,
text,
mentions,
isPoll,
pollCreateData,
pubkey,
addClientTag,
isProtectedEvent,
isNsfw,
highlightedText
}: {
parentStuff: Event | string | undefined
text: string
mentions: string[]
isPoll: boolean
pollCreateData: TPollCreateData
pubkey: string
addClientTag: boolean
isProtectedEvent: boolean
isNsfw: boolean
highlightedText?: string
}) {
const { parentEvent, externalContent } =
typeof parentStuff === 'string'
? { parentEvent: undefined, externalContent: parentStuff }
: { parentEvent: parentStuff, externalContent: undefined }
if (highlightedText && parentEvent) {
return createHighlightDraftEvent(highlightedText, text, parentEvent, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}
if (parentStuff && (externalContent || parentEvent?.kind !== kinds.ShortTextNote)) {
return await createCommentDraftEvent(text, parentStuff, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}
if (isPoll) {
return await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
addClientTag,
isNsfw
})
}
return await createShortTextNoteDraftEvent(text, mentions, {
parentEvent,
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}

View File

@@ -54,7 +54,7 @@ export default function PostOptions({
/> />
</div> </div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')} {t('Show others this was sent via Smesh')}
</div> </div>
</div> </div>

View File

@@ -40,6 +40,7 @@ const PostTextarea = forwardRef<
onUploadStart?: (file: File, cancel: () => void) => void onUploadStart?: (file: File, cancel: () => void) => void
onUploadProgress?: (file: File, progress: number) => void onUploadProgress?: (file: File, progress: number) => void
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
placeholder?: string
} }
>( >(
( (
@@ -52,7 +53,8 @@ const PostTextarea = forwardRef<
className, className,
onUploadStart, onUploadStart,
onUploadProgress, onUploadProgress,
onUploadEnd onUploadEnd,
placeholder
}, },
ref ref
) => { ) => {
@@ -67,6 +69,7 @@ const PostTextarea = forwardRef<
HardBreak, HardBreak,
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder:
placeholder ??
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}), }),
Emoji.configure({ Emoji.configure({

View File

@@ -17,6 +17,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import postEditor from '@/services/post-editor.service' import postEditor from '@/services/post-editor.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Dispatch, useMemo } from 'react' import { Dispatch, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import PostContent from './PostContent' import PostContent from './PostContent'
import Title from './Title' import Title from './Title'
@@ -25,14 +26,17 @@ export default function PostEditor({
parentStuff, parentStuff,
open, open,
setOpen, setOpen,
openFrom openFrom,
highlightedText
}: { }: {
defaultContent?: string defaultContent?: string
parentStuff?: Event | string parentStuff?: Event | string
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
openFrom?: string[] openFrom?: string[]
highlightedText?: string
}) { }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const content = useMemo(() => { const content = useMemo(() => {
@@ -42,9 +46,10 @@ export default function PostEditor({
parentStuff={parentStuff} parentStuff={parentStuff}
close={() => setOpen(false)} close={() => setOpen(false)}
openFrom={openFrom} openFrom={openFrom}
highlightedText={highlightedText}
/> />
) )
}, []) }, [highlightedText])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@@ -64,7 +69,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6"> <div className="space-y-4 px-2 py-6">
<SheetHeader> <SheetHeader>
<SheetTitle className="text-start"> <SheetTitle className="text-start">
<Title parentStuff={parentStuff} /> {highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
</SheetTitle> </SheetTitle>
<SheetDescription className="hidden" /> <SheetDescription className="hidden" />
</SheetHeader> </SheetHeader>
@@ -92,7 +97,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6"> <div className="space-y-4 px-2 py-6">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Title parentStuff={parentStuff} /> {highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
</DialogTitle> </DialogTitle>
<DialogDescription className="hidden" /> <DialogDescription className="hidden" />
</DialogHeader> </DialogHeader>

View File

@@ -6,12 +6,8 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { detectLanguage } from '@/lib/utils'
import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded' import { EmbeddedHashtag, EmbeddedMention, EmbeddedWebsocketUrl } from '../Embedded'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
@@ -25,20 +21,10 @@ export default function ProfileAbout({
emojis?: TEmoji[] emojis?: TEmoji[]
className?: string className?: string
}) { }) {
const { t, i18n } = useTranslation()
const { translateText } = useTranslationService()
const needTranslation = useMemo(() => {
const detected = detectLanguage(about)
if (!detected) return false
if (detected === 'und') return true
return !i18n.language.startsWith(detected)
}, [about, i18n.language])
const [translatedAbout, setTranslatedAbout] = useState<string | null>(null)
const [translating, setTranslating] = useState(false)
const aboutNodes = useMemo(() => { const aboutNodes = useMemo(() => {
if (!about) return null if (!about) return null
const nodes = parseContent(translatedAbout ?? about, [ const nodes = parseContent(about, [
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedUrlParser, EmbeddedUrlParser,
@@ -73,60 +59,7 @@ export default function ProfileAbout({
} }
return node.data return node.data
}) })
}, [about, translatedAbout, emojis]) }, [about, emojis])
const handleTranslate = async () => { return <div className={className}>{aboutNodes}</div>
if (translating || translatedAbout) return
setTranslating(true)
translateText(about ?? '')
.then((translated) => {
setTranslatedAbout(translated)
})
.catch((error) => {
toast.error(
'Translation failed: ' +
(error.message || 'An error occurred while translating the about')
)
})
.finally(() => {
setTranslating(false)
})
}
const handleShowOriginal = () => {
setTranslatedAbout(null)
}
return (
<div>
<div className={className}>{aboutNodes}</div>
{needTranslation && (
<div className="mt-2 text-sm">
{translating ? (
<div className="text-muted-foreground">{t('Translating...')}</div>
) : translatedAbout === null ? (
<button
className="text-primary hover:underline"
onClick={(e) => {
e.stopPropagation()
handleTranslate()
}}
>
{t('Translate')}
</button>
) : (
<button
className="text-primary hover:underline"
onClick={(e) => {
e.stopPropagation()
handleShowOriginal()
}}
>
{t('Show original')}
</button>
)}
</div>
)}
</div>
)
} }

View File

@@ -1,5 +1,4 @@
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Image from '../Image' import Image from '../Image'
@@ -27,7 +26,10 @@ export default function ProfileBanner({
<Image <Image
image={{ url: bannerUrl, pubkey }} image={{ url: bannerUrl, pubkey }}
alt={`${pubkey} banner`} alt={`${pubkey} banner`}
className={cn('rounded-none', className)} className={className}
classNames={{
wrapper: 'rounded-none'
}}
errorPlaceholder={defaultBanner} errorPlaceholder={defaultBanner}
/> />
) )

View File

@@ -1,6 +1,6 @@
import QRCodeStyling from 'qr-code-styling' import QRCodeStyling from 'qr-code-styling'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import iconSvg from '../../assets/favicon.svg' import iconImg from '../../assets/smeshicondark.png'
export default function QrCode({ value, size = 180 }: { value: string; size?: number }) { export default function QrCode({ value, size = 180 }: { value: string; size?: number }) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -13,7 +13,7 @@ export default function QrCode({ value, size = 180 }: { value: string; size?: nu
qrOptions: { qrOptions: {
errorCorrectionLevel: 'M' errorCorrectionLevel: 'M'
}, },
image: iconSvg, image: iconImg,
width: size * pixelRatio, width: size * pixelRatio,
height: size * pixelRatio, height: size * pixelRatio,
data: value, data: value,

View File

@@ -9,7 +9,6 @@ import ContentPreview from '../ContentPreview'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import Stars from '../Stars' import Stars from '../Stars'
import TranslateButton from '../TranslateButton'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
@@ -46,9 +45,6 @@ export default function RelayReviewCard({
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center">
<TranslateButton event={event} className="pr-0" />
</div>
</div> </div>
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" /> <Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
<ContentPreview className="mt-2 line-clamp-4" event={event} /> <ContentPreview className="mt-2 line-clamp-4" event={event} />

View File

@@ -36,9 +36,9 @@ export default function RelayInfo({ url, className }: { url: string; className?:
<div className="px-4 space-y-4"> <div className="px-4 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 justify-between"> <div className="flex items-center gap-2 justify-between">
<div className="flex gap-2 items-center truncate"> <div className="flex gap-2 items-center flex-1">
<RelayIcon url={url} className="w-8 h-8" /> <RelayIcon url={url} className="w-8 h-8" />
<div className="text-2xl font-semibold truncate select-text"> <div className="text-2xl font-semibold truncate select-text flex-1 w-0">
{relayInfo.name || relayInfo.shortUrl} {relayInfo.name || relayInfo.shortUrl}
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@ function RelayControls({ url }: { url: string }) {
} }
const handleCopyShareableUrl = () => { const handleCopyShareableUrl = () => {
navigator.clipboard.writeText(`https://jumble.social/?r=${url}`) navigator.clipboard.writeText(`https://smesh.mleku.dev/?r=${url}`)
setCopiedShareableUrl(true) setCopiedShareableUrl(true)
toast.success('Shareable URL copied to clipboard') toast.success('Shareable URL copied to clipboard')
setTimeout(() => setCopiedShareableUrl(false), 2000) setTimeout(() => setCopiedShareableUrl(false), 2000)

View File

@@ -32,7 +32,16 @@ export default function RelaySimpleInfo({
</div> </div>
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />} {relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
</div> </div>
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>} {!!relayInfo?.description && (
<div
className="line-clamp-3 break-words whitespace-pre-wrap"
style={{
overflowWrap: 'anywhere'
}}
>
{relayInfo.description}
</div>
)}
{!!users?.length && ( {!!users?.length && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-muted-foreground">{t('Favorited by')} </div> <div className="text-muted-foreground">{t('Favorited by')} </div>

View File

@@ -1,11 +1,14 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers } from '@/lib/event' import { useThread } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -15,9 +18,9 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
import StuffStats from '../StuffStats'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton' import StuffStats from '../StuffStats'
import TrustScoreBadge from '../TrustScoreBadge'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
@@ -25,18 +28,23 @@ export default function ReplyNote({
event, event,
parentEventId, parentEventId,
onClickParent = () => {}, onClickParent = () => {},
highlight = false highlight = false,
className = ''
}: { }: {
event: Event event: Event
parentEventId?: string parentEventId?: string
onClickParent?: () => void onClickParent?: () => void
highlight?: boolean highlight?: boolean
className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const eventKey = useMemo(() => getEventKey(event), [event])
const replies = useThread(eventKey)
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
const show = useMemo(() => { const show = useMemo(() => {
if (showMuted) { if (showMuted) {
@@ -50,12 +58,35 @@ export default function ReplyNote({
} }
return true return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers]) }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
const hasReplies = useMemo(() => {
if (!replies || replies.length === 0) {
return false
}
for (const reply of replies) {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
}
if (mutePubkeySet.has(reply.pubkey)) {
continue
}
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
continue
}
return true
}
}, [replies])
return ( return (
<div <div
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`} className={cn(
'relative pb-3 transition-colors duration-500 clickable',
highlight ? 'bg-primary/40' : '',
className
)}
onClick={() => push(toNote(event))} onClick={() => push(toNote(event))}
> >
{hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
<Collapsible> <Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3"> <div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" /> <UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
@@ -68,6 +99,7 @@ export default function ReplyNote({
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate" className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3" skeletonClassName="h-3"
/> />
<TrustScoreBadge pubkey={event.pubkey} className="!size-3.5" />
<ClientTag event={event} /> <ClientTag event={event} />
</div> </div>
<div className="flex items-center gap-1 text-sm text-muted-foreground"> <div className="flex items-center gap-1 text-sm text-muted-foreground">
@@ -79,10 +111,7 @@ export default function ReplyNote({
/> />
</div> </div>
</div> </div>
<div className="flex items-center shrink-0"> <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
<TranslateButton event={event} className="py-0" />
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div> </div>
{parentEventId && ( {parentEventId && (
<ParentNotePreview <ParentNotePreview

View 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>
)
}

View File

@@ -1,339 +1,118 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { import { useAllDescendantThreads } from '@/hooks/useThread'
getEventKey, import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
getKeyFromTag,
getParentTag,
getReplaceableCoordinateFromEvent,
getRootTag,
isMentioningMutedUsers,
isProtectedEvent,
isReplaceableEvent
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import threadService from '@/services/thread.service'
import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import SubReplies from './SubReplies'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
const LIMIT = 100 const LIMIT = 100
const SHOW_COUNT = 10 const SHOW_COUNT = 10
export default function ReplyNoteList({ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
stuff,
index
}: {
stuff: NEvent | string
index?: number
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { push, currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const { stuffKey } = useStuff(stuff)
const { repliesMap, addReplies } = useReply() const allThreads = useAllDescendantThreads(stuffKey)
const { event, externalContent, stuffKey } = useStuff(stuff) const [initialLoading, setInitialLoading] = useState(false)
const replies = useMemo(() => { const replies = useMemo(() => {
const replyKeySet = new Set<string>() const replyKeySet = new Set<string>()
const replyEvents: NEvent[] = [] const thread = allThreads.get(stuffKey) || []
const replyEvents = thread.filter((evt) => {
let parentKeys = [stuffKey] const key = getEventKey(evt)
while (parentKeys.length > 0) { if (replyKeySet.has(key)) return false
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || []) if (mutePubkeySet.has(evt.pubkey)) return false
events.forEach((evt) => { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) {
const key = getEventKey(evt) return false
if (replyKeySet.has(key)) return
if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
replyKeySet.add(key)
replyEvents.push(evt)
})
parentKeys = events.map((evt) => getEventKey(evt))
}
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [stuffKey, repliesMap])
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const fetchRootEvent = async () => {
if (!event && !externalContent) return
let root: TRootInfo = event
? isReplaceableEvent(event.kind)
? {
type: 'A',
id: getReplaceableCoordinateFromEvent(event),
pubkey: event.pubkey,
relay: client.getEventHint(event.id)
}
: { type: 'E', id: event.id, pubkey: event.pubkey }
: { type: 'I', id: externalContent! }
const rootTag = getRootTag(event)
if (rootTag?.type === 'e') {
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
if (rootEventHexId && rootEventPubkey) {
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
} else {
const rootEventId = generateBech32IdFromETag(rootTag.tag)
if (rootEventId) {
const rootEvent = await client.fetchEvent(rootEventId)
if (rootEvent) {
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
}
}
}
} else if (rootTag?.type === 'a') {
const [, coordinate, relay] = rootTag.tag
const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, pubkey, relay }
} else if (rootTag?.type === 'i') {
root = { type: 'I', id: rootTag.tag[1] }
} }
setRootInfo(root) if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
} const replyKey = getEventKey(evt)
fetchRootEvent() const repliesForThisReply = allThreads.get(replyKey)
}, [event]) // If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
useEffect(() => { !repliesForThisReply ||
if (loading || !rootInfo || currentIndex !== index) return repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
const init = async () => { return false
setLoading(true)
try {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
} }
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
// If current event is protected, we can assume its replies are also protected and stored on the same relays
if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
} else if (rootInfo.type === 'A') {
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
}
)
if (rootInfo.relay) {
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
}
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{
onEvents: (evts, eosed) => {
if (evts.length > 0) {
addReplies(evts)
}
if (eosed) {
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
setLoading(false)
}
},
onNew: (evt) => {
addReplies([evt])
}
}
)
setTimelineKey(timelineKey)
return closer
} catch {
setLoading(false)
} }
return
}
const promise = init() replyKeySet.add(key)
return () => { return true
promise.then((closer) => closer?.()) })
} return replyEvents.sort((a, b) => b.created_at - a.created_at)
}, [rootInfo, currentIndex, index]) }, [
stuffKey,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions,
isUserTrusted
])
// Initial subscription
useEffect(() => { useEffect(() => {
if (replies.length === 0) { const loadInitial = async () => {
loadMore() setInitialLoading(true)
} await threadService.subscribe(stuff, LIMIT)
}, [replies]) setInitialLoading(false)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
} }
const observerInstance = new IntersectionObserver((entries) => { loadInitial()
if (entries[0].isIntersecting && showCount < replies.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => { return () => {
if (observerInstance && currentBottomRef) { threadService.unsubscribe(stuff)
observerInstance.unobserve(currentBottomRef)
}
} }
}, [replies, showCount]) }, [stuff])
const loadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (loading || !until || !timelineKey) return return await threadService.loadMore(stuff, LIMIT)
}, [stuff])
setLoading(true) const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) items: replies,
addReplies(events) showCount: SHOW_COUNT,
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) onLoadMore: handleLoadMore,
setLoading(false) initialLoading
}, [loading, until, timelineKey]) })
const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
let found = false
if (scrollTo) {
const ref = replyRefs.current[key]
if (ref) {
found = true
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
if (!found) {
if (eventId) push(toNote(eventId))
return
}
setHighlightReplyKey(key)
setTimeout(() => {
setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
}, 1500)
}, [])
return ( return (
<div className="min-h-[80vh]"> <div className="min-h-[80vh]">
{loading && <LoadingBar />} {(loading || initialLoading) && <LoadingBar />}
{!loading && until && (!event || until > event.created_at) && (
<div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{t('load more older replies')}
</div>
)}
<div> <div>
{replies.slice(0, showCount).map((reply) => { {visibleItems.map((reply) => (
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { <Item key={reply.id} reply={reply} />
const replyKey = getEventKey(reply) ))}
const repliesForThisReply = repliesMap.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
) {
return null
}
}
const rootKey = event ? getEventKey(event) : externalContent!
const currentReplyKey = getEventKey(reply)
const parentTag = getParentTag(reply)
const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
const parentEventId = parentTag
? parentTag.type === 'e'
? generateBech32IdFromETag(parentTag.tag)
: parentTag.type === 'a'
? generateBech32IdFromATag(parentTag.tag)
: undefined
: undefined
return (
<div
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
key={currentReplyKey}
className="scroll-mt-12"
>
<ReplyNote
event={reply}
parentEventId={rootKey !== parentKey ? parentEventId : undefined}
onClickParent={() => {
if (!parentKey) return
highlightReply(parentKey, parentEventId)
}}
highlight={highlightReplyKey === currentReplyKey}
/>
</div>
)
})}
</div> </div>
{!loading && ( <div ref={bottomRef} />
{shouldShowLoadingIndicator ? (
<ReplyNoteSkeleton />
) : (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{replies.length > 0 ? t('no more replies') : t('no replies')} {replies.length > 0 ? t('no more replies') : t('no replies')}
</div> </div>
)} )}
<div ref={bottomRef} /> </div>
{loading && <ReplyNoteSkeleton />} )
}
function Item({ reply }: { reply: NEvent }) {
const key = useMemo(() => getEventKey(reply), [reply])
return (
<div className="relative border-b">
<ReplyNote event={reply} />
<SubReplies parentKey={key} />
</div> </div>
) )
} }

View File

@@ -1,102 +1,586 @@
import AboutInfoDialog from '@/components/AboutInfoDialog' import AboutInfoDialog from '@/components/AboutInfoDialog'
import Donation from '@/components/Donation' import Donation from '@/components/Donation'
import Emoji from '@/components/Emoji'
import EmojiPackList from '@/components/EmojiPackList'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import MailboxSetting from '@/components/MailboxSetting'
import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import { import {
toAppearanceSettings, Accordion,
toEmojiPackSettings, AccordionContent,
toGeneralSettings, AccordionItem,
toPostSettings, AccordionTrigger
toRelaySettings, } from '@/components/ui/accordion'
toSystemSettings, import {
toTranslation, AlertDialog,
toWallet AlertDialogAction,
} from '@/lib/link' AlertDialogCancel,
import { cn } from '@/lib/utils' AlertDialogContent,
import { useSecondaryPage } from '@/PageManager' AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs as RadixTabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
BIG_RELAY_URLS,
DEFAULT_FAVICON_URL_TEMPLATE,
MEDIA_AUTO_LOAD_POLICY,
NSFW_DISPLAY_POLICY,
PRIMARY_COLORS,
TPrimaryColor
} from '@/constants'
import { LocalizedLanguageNames, TLanguage } from '@/i18n'
import { cn, isSupportCheckConnectionType } from '@/lib/utils'
import MediaUploadServiceSetting from '@/pages/secondary/PostSettingsPage/MediaUploadServiceSetting'
import DefaultZapAmountInput from '@/pages/secondary/WalletPage/DefaultZapAmountInput'
import DefaultZapCommentInput from '@/pages/secondary/WalletPage/DefaultZapCommentInput'
import LightningAddressInput from '@/pages/secondary/WalletPage/LightningAddressInput'
import QuickZapSwitch from '@/pages/secondary/WalletPage/QuickZapSwitch'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { useZap } from '@/providers/ZapProvider'
import storage from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { import {
Check, Check,
ChevronRight,
Cog, Cog,
Columns2,
Copy, Copy,
Info, Info,
KeyRound, KeyRound,
Languages, LayoutList,
List,
Monitor,
Moon,
Palette, Palette,
PanelLeft,
PencilLine, PencilLine,
RotateCcw,
Server, Server,
Settings2, Settings2,
Smile, Smile,
Sun,
Wallet Wallet
} from 'lucide-react' } from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react' import { kinds } from 'nostr-tools'
import { forwardRef, HTMLProps, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type TEmojiTab = 'my-packs' | 'explore'
const THEMES = [
{ key: 'system', label: 'System', icon: <Monitor className="size-5" /> },
{ key: 'light', label: 'Light', icon: <Sun className="size-5" /> },
{ key: 'dark', label: 'Dark', icon: <Moon className="size-5" /> },
{ key: 'pure-black', label: 'Pure Black', icon: <Moon className="size-5 fill-current" /> }
] as const
const LAYOUTS = [
{ key: false, label: 'Two-column', icon: <Columns2 className="size-5" /> },
{ key: true, label: 'Single-column', icon: <PanelLeft className="size-5" /> }
] as const
const NOTIFICATION_STYLES = [
{ key: 'detailed', label: 'Detailed', icon: <LayoutList className="size-5" /> },
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
] as const
export default function Settings() { export default function Settings() {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr() const { pubkey, nsec, ncryptsec } = useNostr()
const { push } = useSecondaryPage() const { isSmallScreen } = useScreenSize()
const [copiedNsec, setCopiedNsec] = useState(false) const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
const [openSection, setOpenSection] = useState<string>('')
const accordionRef = useRef<HTMLDivElement>(null)
// General settings
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const {
autoplay,
setAutoplay,
nsfwDisplayPolicy,
setNsfwDisplayPolicy,
hideContentMentioningMutedUsers,
setHideContentMentioningMutedUsers,
mediaAutoLoadPolicy,
setMediaAutoLoadPolicy,
faviconUrlTemplate,
setFaviconUrlTemplate
} = useContentPolicy()
const {
hideUntrustedNotes,
updateHideUntrustedNotes,
hideUntrustedInteractions,
updateHideUntrustedInteractions,
hideUntrustedNotifications,
updateHideUntrustedNotifications
} = useUserTrust()
const {
quickReaction,
updateQuickReaction,
quickReactionEmoji,
updateQuickReactionEmoji,
enableSingleColumnLayout,
updateEnableSingleColumnLayout,
notificationListStyle,
updateNotificationListStyle
} = useUserPreferences()
// Appearance settings
const { themeSetting, setThemeSetting, primaryColor, setPrimaryColor } = useTheme()
// Wallet settings
const { isWalletConnected, walletInfo } = useZap()
// Relay settings
const [relayTabValue, setRelayTabValue] = useState('favorite-relays')
// Emoji settings
const [emojiTab, setEmojiTab] = useState<TEmojiTab>('my-packs')
// System settings
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
}
const handleAccordionChange = useCallback((value: string) => {
setOpenSection(value)
if (value) {
// Scroll the opened section into view
setTimeout(() => {
const item = accordionRef.current?.querySelector(`[data-state="open"]`)
if (item) {
const rect = item.getBoundingClientRect()
const scrollContainer = accordionRef.current?.closest('[data-radix-scroll-area-viewport]') || window
if (scrollContainer === window) {
const scrollTop = window.scrollY + rect.top - 16
window.scrollTo({ top: scrollTop, behavior: 'smooth' })
} else {
const containerRect = (scrollContainer as HTMLElement).getBoundingClientRect()
const scrollTop = (scrollContainer as HTMLElement).scrollTop + rect.top - containerRect.top - 16
;(scrollContainer as HTMLElement).scrollTo({ top: scrollTop, behavior: 'smooth' })
}
}
}, 50)
}
}, [])
return ( return (
<div> <div ref={accordionRef}>
<SettingItem className="clickable" onClick={() => push(toGeneralSettings())}> <Accordion
<div className="flex items-center gap-4"> type="single"
<Settings2 /> collapsible
<div>{t('General')}</div> value={openSection}
</div> onValueChange={handleAccordionChange}
<ChevronRight /> className="w-full"
</SettingItem> >
<SettingItem className="clickable" onClick={() => push(toAppearanceSettings())}> {/* General */}
<div className="flex items-center gap-4"> <AccordionItem value="general">
<Palette /> <AccordionTrigger className="px-4 hover:no-underline">
<div>{t('Appearance')}</div> <div className="flex items-center gap-4">
</div> <Settings2 className="size-4" />
<ChevronRight /> <span>{t('General')}</span>
</SettingItem> </div>
<SettingItem className="clickable" onClick={() => push(toRelaySettings())}> </AccordionTrigger>
<div className="flex items-center gap-4"> <AccordionContent className="px-4 space-y-4">
<Server /> <SettingItem>
<div>{t('Relays')}</div> <Label htmlFor="languages" className="text-base font-normal">
</div> {t('Languages')}
<ChevronRight /> </Label>
</SettingItem> <Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
{!!pubkey && ( <SelectTrigger id="languages" className="w-48">
<SettingItem className="clickable" onClick={() => push(toTranslation())}> <SelectValue />
<div className="flex items-center gap-4"> </SelectTrigger>
<Languages /> <SelectContent>
<div>{t('Translation')}</div> {Object.entries(LocalizedLanguageNames).map(([key, value]) => (
</div> <SelectItem key={key} value={key}>
<ChevronRight /> {value}
</SettingItem> </SelectItem>
)} ))}
{!!pubkey && ( </SelectContent>
<SettingItem className="clickable" onClick={() => push(toWallet())}> </Select>
<div className="flex items-center gap-4"> </SettingItem>
<Wallet /> <SettingItem>
<div>{t('Wallet')}</div> <Label htmlFor="media-auto-load-policy" className="text-base font-normal">
</div> {t('Auto-load media')}
<ChevronRight /> </Label>
</SettingItem> <Select
)} defaultValue="wifi-only"
{!!pubkey && ( value={mediaAutoLoadPolicy}
<SettingItem className="clickable" onClick={() => push(toPostSettings())}> onValueChange={(value: TMediaAutoLoadPolicy) => setMediaAutoLoadPolicy(value)}
<div className="flex items-center gap-4"> >
<PencilLine /> <SelectTrigger id="media-auto-load-policy" className="w-48">
<div>{t('Post settings')}</div> <SelectValue />
</div> </SelectTrigger>
<ChevronRight /> <SelectContent>
</SettingItem> <SelectItem value={MEDIA_AUTO_LOAD_POLICY.ALWAYS}>{t('Always')}</SelectItem>
)} {isSupportCheckConnectionType() && (
{!!pubkey && ( <SelectItem value={MEDIA_AUTO_LOAD_POLICY.WIFI_ONLY}>{t('Wi-Fi only')}</SelectItem>
<SettingItem className="clickable" onClick={() => push(toEmojiPackSettings())}> )}
<div className="flex items-center gap-4"> <SelectItem value={MEDIA_AUTO_LOAD_POLICY.NEVER}>{t('Never')}</SelectItem>
<Smile /> </SelectContent>
<div>{t('Emoji Packs')}</div> </Select>
</div> </SettingItem>
<ChevronRight /> <SettingItem>
</SettingItem> <Label htmlFor="autoplay" className="text-base font-normal">
)} <div>{t('Autoplay')}</div>
<div className="text-muted-foreground">{t('Enable video autoplay on this device')}</div>
</Label>
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-notes" className="text-base font-normal">
{t('Hide untrusted notes')}
</Label>
<Switch
id="hide-untrusted-notes"
checked={hideUntrustedNotes}
onCheckedChange={updateHideUntrustedNotes}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-interactions" className="text-base font-normal">
{t('Hide untrusted interactions')}
</Label>
<Switch
id="hide-untrusted-interactions"
checked={hideUntrustedInteractions}
onCheckedChange={updateHideUntrustedInteractions}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-untrusted-notifications" className="text-base font-normal">
{t('Hide untrusted notifications')}
</Label>
<Switch
id="hide-untrusted-notifications"
checked={hideUntrustedNotifications}
onCheckedChange={updateHideUntrustedNotifications}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="hide-content-mentioning-muted-users" className="text-base font-normal">
{t('Hide content mentioning muted users')}
</Label>
<Switch
id="hide-content-mentioning-muted-users"
checked={hideContentMentioningMutedUsers}
onCheckedChange={setHideContentMentioningMutedUsers}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="nsfw-display-policy" className="text-base font-normal">
{t('NSFW content display')}
</Label>
<Select
value={nsfwDisplayPolicy}
onValueChange={(value: TNsfwDisplayPolicy) => setNsfwDisplayPolicy(value)}
>
<SelectTrigger id="nsfw-display-policy" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE}>{t('Hide completely')}</SelectItem>
<SelectItem value={NSFW_DISPLAY_POLICY.HIDE_CONTENT}>{t('Show but hide content')}</SelectItem>
<SelectItem value={NSFW_DISPLAY_POLICY.SHOW}>{t('Show directly')}</SelectItem>
</SelectContent>
</Select>
</SettingItem>
<SettingItem>
<Label htmlFor="quick-reaction" className="text-base font-normal">
<div>{t('Quick reaction')}</div>
<div className="text-muted-foreground">
{t('If enabled, you can react with a single click. Click and hold for more options')}
</div>
</Label>
<Switch id="quick-reaction" checked={quickReaction} onCheckedChange={updateQuickReaction} />
</SettingItem>
{quickReaction && (
<SettingItem>
<Label htmlFor="quick-reaction-emoji" className="text-base font-normal">
{t('Quick reaction emoji')}
</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => updateQuickReactionEmoji('+')}
className="text-muted-foreground hover:text-foreground"
>
<RotateCcw />
</Button>
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (!emoji) return
updateQuickReactionEmoji(emoji)
}}
>
<Button variant="ghost" size="icon" className="border">
<Emoji emoji={quickReactionEmoji} />
</Button>
</EmojiPickerDialog>
</div>
</SettingItem>
)}
</AccordionContent>
</AccordionItem>
{/* Appearance */}
<AccordionItem value="appearance">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Palette className="size-4" />
<span>{t('Appearance')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<div className="flex flex-col gap-2">
<Label className="text-base">{t('Theme')}</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
{THEMES.map(({ key, label, icon }) => (
<OptionButton
key={key}
isSelected={themeSetting === key}
icon={icon}
label={t(label)}
onClick={() => setThemeSetting(key)}
/>
))}
</div>
</div>
{!isSmallScreen && (
<div className="flex flex-col gap-2">
<Label className="text-base">{t('Layout')}</Label>
<div className="grid grid-cols-2 gap-4 w-full">
{LAYOUTS.map(({ key, label, icon }) => (
<OptionButton
key={key.toString()}
isSelected={enableSingleColumnLayout === key}
icon={icon}
label={t(label)}
onClick={() => updateEnableSingleColumnLayout(key)}
/>
))}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">{t('Notification list style')}</Label>
<div className="grid grid-cols-2 gap-4 w-full">
{NOTIFICATION_STYLES.map(({ key, label, icon }) => (
<OptionButton
key={key}
isSelected={notificationListStyle === key}
icon={icon}
label={t(label)}
onClick={() => updateNotificationListStyle(key)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">{t('Primary color')}</Label>
<div className="grid grid-cols-4 gap-4 w-full">
{Object.entries(PRIMARY_COLORS).map(([key, config]) => (
<OptionButton
key={key}
isSelected={primaryColor === key}
icon={
<div
className="size-8 rounded-full shadow-md"
style={{ backgroundColor: `hsl(${config.light.primary})` }}
/>
}
label={t(config.name)}
onClick={() => setPrimaryColor(key as TPrimaryColor)}
/>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Relays */}
<AccordionItem value="relays">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Server className="size-4" />
<span>{t('Relays')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<RadixTabs value={relayTabValue} onValueChange={setRelayTabValue} className="space-y-4">
<TabsList>
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
</TabsList>
<TabsContent value="favorite-relays">
<FavoriteRelaysSetting />
</TabsContent>
<TabsContent value="mailbox">
<MailboxSetting />
</TabsContent>
</RadixTabs>
</AccordionContent>
</AccordionItem>
{/* Wallet */}
{!!pubkey && (
<AccordionItem value="wallet">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Wallet className="size-4" />
<span>{t('Wallet')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
{isWalletConnected ? (
<>
<div>
{walletInfo?.node.alias && (
<div className="mb-2">
{t('Connected to')} <strong>{walletInfo.node.alias}</strong>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">{t('Disconnect Wallet')}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Are you absolutely sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('You will not be able to send zaps to others.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => disconnect()}>
{t('Disconnect')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<QuickZapSwitch />
<LightningAddressInput />
</>
) : (
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
{t('Connect Wallet')}
</Button>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
{/* Post Settings */}
{!!pubkey && (
<AccordionItem value="posts">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<PencilLine className="size-4" />
<span>{t('Post settings')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<MediaUploadServiceSetting />
</AccordionContent>
</AccordionItem>
)}
{/* Emoji Packs */}
{!!pubkey && (
<AccordionItem value="emoji-packs">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Smile className="size-4" />
<span>{t('Emoji Packs')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4">
<Tabs
value={emojiTab}
tabs={[
{ value: 'my-packs', label: 'My Packs' },
{ value: 'explore', label: 'Explore' }
]}
onTabChange={(tab) => setEmojiTab(tab as TEmojiTab)}
/>
{emojiTab === 'my-packs' ? (
<EmojiPackList />
) : (
<NoteList
showKinds={[kinds.Emojisets]}
subRequests={[{ urls: BIG_RELAY_URLS, filter: {} }]}
hideUntrustedNotes={hideUntrustedNotes}
/>
)}
</AccordionContent>
</AccordionItem>
)}
{/* System */}
<AccordionItem value="system">
<AccordionTrigger className="px-4 hover:no-underline">
<div className="flex items-center gap-4">
<Cog className="size-4" />
<span>{t('System')}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="favicon-url" className="text-base font-normal">
{t('Favicon URL')}
</Label>
<Input
id="favicon-url"
type="text"
value={faviconUrlTemplate}
onChange={(e) => setFaviconUrlTemplate(e.target.value)}
placeholder={DEFAULT_FAVICON_URL_TEMPLATE}
/>
</div>
<SettingItem>
<Label htmlFor="filter-out-onion-relays" className="text-base font-normal">
{t('Filter out onion relays')}
</Label>
<Switch
id="filter-out-onion-relays"
checked={filterOutOnionRelays}
onCheckedChange={(checked) => {
storage.setFilterOutOnionRelays(checked)
setFilterOutOnionRelays(checked)
}}
/>
</SettingItem>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Non-accordion items */}
{!!nsec && ( {!!nsec && (
<SettingItem <SettingItem
className="clickable" className="clickable"
@@ -129,13 +613,6 @@ export default function Settings() {
{copiedNcryptsec ? <Check /> : <Copy />} {copiedNcryptsec ? <Check /> : <Copy />}
</SettingItem> </SettingItem>
)} )}
<SettingItem className="clickable" onClick={() => push(toSystemSettings())}>
<div className="flex items-center gap-4">
<Cog />
<div>{t('System')}</div>
</div>
<ChevronRight />
</SettingItem>
<AboutInfoDialog> <AboutInfoDialog>
<SettingItem className="clickable"> <SettingItem className="clickable">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -146,7 +623,6 @@ export default function Settings() {
<div className="text-muted-foreground"> <div className="text-muted-foreground">
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT}) v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT})
</div> </div>
<ChevronRight />
</div> </div>
</SettingItem> </SettingItem>
</AboutInfoDialog> </AboutInfoDialog>
@@ -162,7 +638,7 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
return ( return (
<div <div
className={cn( className={cn(
'flex justify-between select-none items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0', 'flex justify-between select-none items-center px-4 min-h-9 [&_svg]:size-4 [&_svg]:shrink-0',
className className
)} )}
{...props} {...props}
@@ -174,3 +650,28 @@ const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
} }
) )
SettingItem.displayName = 'SettingItem' SettingItem.displayName = 'SettingItem'
const OptionButton = ({
isSelected,
onClick,
icon,
label
}: {
isSelected: boolean
onClick: () => void
icon: React.ReactNode
label: string
}) => {
return (
<button
onClick={onClick}
className={cn(
'flex flex-col items-center gap-2 py-4 rounded-lg border-2 transition-all',
isSelected ? 'border-primary' : 'border-border hover:border-muted-foreground/40'
)}
>
<div className="flex items-center justify-center w-8 h-8">{icon}</div>
<span className="text-xs font-medium">{label}</span>
</button>
)
}

View File

@@ -1,10 +1,10 @@
import { useStuff } from '@/hooks/useStuff' import { useStuff } from '@/hooks/useStuff'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event' import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff) const { event, stuffKey } = useStuff(stuff)
const { repliesMap } = useReply() const allThreads = useAllDescendantThreads(stuffKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => { const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey const hasReplied = pubkey
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey) ? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
: false : false
let replyCount = 0 let replyCount = 0
const replies = [...(repliesMap.get(stuffKey)?.events || [])] const replies = [...(allThreads.get(stuffKey) ?? [])]
while (replies.length > 0) { while (replies.length > 0) {
const reply = replies.pop() const reply = replies.pop()
if (!reply) break if (!reply) break
const replyKey = getEventKey(reply) const replyKey = getEventKey(reply)
const nestedReplies = repliesMap.get(replyKey)?.events ?? [] const nestedReplies = allThreads.get(replyKey) ?? []
replies.push(...nestedReplies) replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
} }
return { replyCount, hasReplied } return { replyCount, hasReplied }
}, [repliesMap, event, stuffKey, hideUntrustedInteractions]) }, [allThreads, event, stuffKey, hideUntrustedInteractions])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (

View File

@@ -34,7 +34,7 @@ export default function TooManyRelaysAlertDialog() {
const dismissed = storage.getDismissedTooManyRelaysAlert() const dismissed = storage.getDismissedTooManyRelaysAlert()
if (dismissed) return if (dismissed) return
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) { if (relayList && (relayList.read.length > 5 || relayList.write.length > 5)) {
setOpen(true) setOpen(true)
} else { } else {
setOpen(false) setOpen(false)

View File

@@ -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>
)
}

View File

@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { usePinnedUsers } from '@/providers/PinnedUsersProvider' import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import threadService from '@/services/thread.service'
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service' import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
const { pinnedPubkeySet } = usePinnedUsers() const { pinnedPubkeySet } = usePinnedUsers()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix()) const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
if (eosed) { if (eosed) {
setLoading(false) setLoading(false)
setHasMore(events.length > 0) setHasMore(events.length > 0)
addReplies(events) threadService.addRepliesToThread(events)
} }
}, },
onNew: (event) => { onNew: (event) => {
setNewEvents((oldEvents) => setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
) )
addReplies([event]) threadService.addRepliesToThread([event])
}, },
onClose: (url, reason) => { onClose: (url, reason) => {
if (!showRelayCloseReason) return if (!showRelayCloseReason) return

View File

@@ -68,9 +68,9 @@ export default function WebPreview({
{image && ( {image && (
<Image <Image
image={{ url: image }} image={{ url: image }}
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 rounded-none border-r" className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
classNames={{ classNames={{
skeleton: 'rounded-none border-r' wrapper: 'rounded-none border-r'
}} }}
hideIfError hideIfError
/> />

View 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 }

View File

@@ -25,7 +25,7 @@ const buttonVariants = cva(
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-lg px-8', lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9 shrink-0',
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5' 'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
} }
}, },

View File

@@ -1,6 +1,6 @@
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social' export const SMESH_API_BASE_URL = 'https://api.smesh.social'
export const RECOMMENDED_BLOSSOM_SERVERS = [ export const RECOMMENDED_BLOSSOM_SERVERS = [
'https://blossom.band/', 'https://blossom.band/',
@@ -62,12 +62,17 @@ export const ApplicationDataKey = {
export const BIG_RELAY_URLS = [ export const BIG_RELAY_URLS = [
'wss://relay.damus.io/', 'wss://relay.damus.io/',
'wss://relay.nostr.band/', 'wss://nos.lol/',
'wss://relay.primal.net/', 'wss://relay.primal.net/',
'wss://nos.lol/' 'wss://offchain.pub/'
] ]
export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.nos.today/'] export const SEARCHABLE_RELAY_URLS = [
'wss://search.nos.today/',
'wss://relay.ditto.pub/',
'wss://relay.nostrcheck.me/',
'wss://relay.nostr.band/'
]
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/'] export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
@@ -134,8 +139,8 @@ export const YOUTUBE_URL_REGEX =
export const X_URL_REGEX = export const X_URL_REGEX =
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi /https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi
export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a' export const SMESH_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883' export const CODY_PUBKEY = '4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f'
export const NIP_96_SERVICE = [ export const NIP_96_SERVICE = [
'https://mockingyou.com', 'https://mockingyou.com',

View File

@@ -5,5 +5,5 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo' export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos' export * from './useFetchRelayInfos'
export * from './useFetchRelayList' export * from './useFetchRelayList'
export * from './useInfiniteScroll'
export * from './useSearchProfiles' export * from './useSearchProfiles'
export * from './useTranslatedEvent'

View File

@@ -1,5 +1,4 @@
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -7,7 +6,6 @@ import { useEffect, useState } from 'react'
export function useFetchEvent(eventId?: string) { export function useFetchEvent(eventId?: string) {
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
const { addReplies } = useReply()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [event, setEvent] = useState<Event | undefined>(undefined) const [event, setEvent] = useState<Event | undefined>(undefined)
@@ -23,7 +21,6 @@ export function useFetchEvent(eventId?: string) {
const event = await client.fetchEvent(eventId) const event = await client.fetchEvent(eventId)
if (event && !isEventDeleted(event)) { if (event && !isEventDeleted(event)) {
setEvent(event) setEvent(event)
addReplies([event])
} }
} }

View 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
View 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)
)
}

View File

@@ -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
}

View File

@@ -19,6 +19,7 @@ import pt_PT from './locales/pt-PT'
import ru from './locales/ru' import ru from './locales/ru'
import th from './locales/th' import th from './locales/th'
import zh from './locales/zh' import zh from './locales/zh'
import zh_TW from './locales/zh-TW'
const languages = { const languages = {
ar: { resource: ar, name: '╪з┘Д╪╣╪▒╪и┘К╪й' }, ar: { resource: ar, name: '╪з┘Д╪╣╪▒╪и┘К╪й' },
@@ -37,7 +38,8 @@ const languages = {
'pt-PT': { resource: pt_PT, name: 'Portugu├кs (Portugal)' }, 'pt-PT': { resource: pt_PT, name: 'Portugu├кs (Portugal)' },
ru: { resource: ru, name: '╨а╤Г╤Б╤Б╨║╨╕╨╣' }, ru: { resource: ru, name: '╨а╤Г╤Б╤Б╨║╨╕╨╣' },
th: { resource: th, name: 'р╣Др╕Чр╕в' }, th: { resource: th, name: 'р╣Др╕Чр╕в' },
zh: { resource: zh, name: 'чоАф╜Уф╕нцЦЗ' } zh: { resource: zh, name: 'чоАф╜Уф╕нцЦЗ' },
'zh-TW': { resource: zh_TW, name: 'ч╣БщлФф╕нцЦЗ' }
} as const } as const
export type TLanguage = keyof typeof languages export type TLanguage = keyof typeof languages
@@ -62,6 +64,10 @@ i18n
}, },
detection: { detection: {
convertDetectedLanguage: (lng) => { convertDetectedLanguage: (lng) => {
console.log('Detected language:', lng)
if (lng.startsWith('zh')) {
return ['zh', 'zh-CN', 'zh-SG'].includes(lng) ? 'zh' : 'zh-TW'
}
const supported = supportedLanguages.find((supported) => lng.startsWith(supported)) const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
return supported || 'en' return supported || 'en'
} }
@@ -71,6 +77,7 @@ i18n
i18n.services.formatter?.add('date', (timestamp, lng) => { i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) { switch (lng) {
case 'zh': case 'zh':
case 'zh-TW':
case 'ja': case 'ja':
return dayjs(timestamp).format('YYYYх╣┤MMцЬИDDцЧе') return dayjs(timestamp).format('YYYYх╣┤MMцЬИDDцЧе')
case 'pl': case 'pl':

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': '╪е╪╢╪з┘Б╪й ╪н╪│╪з╪и', 'Add an Account': '╪е╪╢╪з┘Б╪й ╪н╪│╪з╪и',
'More options': '╪з┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪о┘К╪з╪▒╪з╪к', 'More options': '╪з┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪о┘К╪з╪▒╪з╪к',
'Add client tag': '╪е╪╢╪з┘Б╪й ┘И╪│┘Е ╪з┘Д╪╣┘Е┘К┘Д', 'Add client tag': '╪е╪╢╪з┘Б╪й ┘И╪│┘Е ╪з┘Д╪╣┘Е┘К┘Д',
'Show others this was sent via Jumble': '╪╣╪▒╪╢ ╪г┘Ж ┘З╪░┘З ╪з┘Д╪▒╪│╪з┘Д╪й ╪г┘П╪▒╪│┘Д╪к ╪╣╪и╪▒ Jumble', 'Show others this was sent via Smesh': '╪╣╪▒╪╢ ╪г┘Ж ┘З╪░┘З ╪з┘Д╪▒╪│╪з┘Д╪й ╪г┘П╪▒╪│┘Д╪к ╪╣╪и╪▒ Smesh',
'Are you sure you want to logout?': '┘З┘Д ╪г┘Ж╪к ┘Е╪к╪г┘Г╪п ╪г┘Ж┘Г ╪к╪▒┘К╪п ╪к╪│╪м┘К┘Д ╪з┘Д╪о╪▒┘И╪м╪Я', 'Are you sure you want to logout?': '┘З┘Д ╪г┘Ж╪к ┘Е╪к╪г┘Г╪п ╪г┘Ж┘Г ╪к╪▒┘К╪п ╪к╪│╪м┘К┘Д ╪з┘Д╪о╪▒┘И╪м╪Я',
'relay sets': '┘Е╪м┘Е┘И╪╣╪з╪к ╪з┘Д╪▒┘К┘Д╪з┘К', 'relay sets': '┘Е╪м┘Е┘И╪╣╪з╪к ╪з┘Д╪▒┘К┘Д╪з┘К',
edit: '╪к╪╣╪п┘К┘Д', edit: '╪к╪╣╪п┘К┘Д',
@@ -195,9 +195,9 @@ export default {
All: '╪з┘Д┘Г┘Д', All: '╪з┘Д┘Г┘Д',
Reactions: '╪з┘Д╪к┘Б╪з╪╣┘Д╪з╪к', Reactions: '╪з┘Д╪к┘Б╪з╪╣┘Д╪з╪к',
Zaps: 'Zaps', Zaps: 'Zaps',
'Enjoying Jumble?': '┘З┘Д ╪к╪│╪к┘Е╪к╪╣ ╪и┘А Jumble╪Я', 'Enjoying Smesh?': '┘З┘Д ╪к╪│╪к┘Е╪к╪╣ ╪и┘А Smesh╪Я',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'╪к╪и╪▒╪╣┘Г ┘К╪│╪з╪╣╪п ┘Б┘К ╪╡┘К╪з┘Ж╪й Jumble ┘И╪к╪н╪│┘К┘Ж┘З! ЁЯШК', '╪к╪и╪▒╪╣┘Г ┘К╪│╪з╪╣╪п ┘Б┘К ╪╡┘К╪з┘Ж╪й Smesh ┘И╪к╪н╪│┘К┘Ж┘З! ЁЯШК',
'Earlier notifications': '╪з┘Д╪е╪┤╪╣╪з╪▒╪з╪к ╪з┘Д╪│╪з╪и┘В╪й', 'Earlier notifications': '╪з┘Д╪е╪┤╪╣╪з╪▒╪з╪к ╪з┘Д╪│╪з╪и┘В╪й',
'Temporarily display this note': '╪╣╪▒╪╢ ┘З╪░┘З ╪з┘Д┘Е┘Д╪з╪н╪╕╪й ┘Е╪д┘В╪к╪з┘Л', 'Temporarily display this note': '╪╣╪▒╪╢ ┘З╪░┘З ╪з┘Д┘Е┘Д╪з╪н╪╕╪й ┘Е╪д┘В╪к╪з┘Л',
buttonFollowing: '╪м╪з╪▒┘Н ╪з┘Д┘Е╪к╪з╪и╪╣╪й', buttonFollowing: '╪м╪з╪▒┘Н ╪з┘Д┘Е╪к╪з╪и╪╣╪й',
@@ -250,7 +250,7 @@ export default {
Translation: '╪з┘Д╪к╪▒╪м┘Е╪й', Translation: '╪з┘Д╪к╪▒╪м┘Е╪й',
Balance: '╪з┘Д╪▒╪╡┘К╪п', Balance: '╪з┘Д╪▒╪╡┘К╪п',
characters: '╪з┘Д╪н╪▒┘И┘Б', characters: '╪з┘Д╪н╪▒┘И┘Б',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'┘К┘Е┘Г┘Ж┘Г ╪з╪│╪к╪о╪п╪з┘Е ┘Е┘Б╪к╪з╪н API ┘З╪░╪з ┘Б┘К ╪г┘К ┘Е┘Г╪з┘Ж ╪в╪о╪▒ ┘К╪п╪╣┘Е LibreTranslate. ╪╣┘Ж┘И╪з┘Ж ╪з┘Д╪о╪п┘Е╪й ┘З┘И {{serviceUrl}}', '┘К┘Е┘Г┘Ж┘Г ╪з╪│╪к╪о╪п╪з┘Е ┘Е┘Б╪к╪з╪н API ┘З╪░╪з ┘Б┘К ╪г┘К ┘Е┘Г╪з┘Ж ╪в╪о╪▒ ┘К╪п╪╣┘Е LibreTranslate. ╪╣┘Ж┘И╪з┘Ж ╪з┘Д╪о╪п┘Е╪й ┘З┘И {{serviceUrl}}',
'Top up': '╪е╪╣╪з╪п╪й ╪┤╪н┘Ж', 'Top up': '╪е╪╣╪з╪п╪й ╪┤╪н┘Ж',
'Will receive: {n} characters': '╪│╪к╪к┘Д┘В┘Й: {{n}} ╪н╪▒┘И┘Б', 'Will receive: {n} characters': '╪│╪к╪к┘Д┘В┘Й: {{n}} ╪н╪▒┘И┘Б',
@@ -384,6 +384,7 @@ export default {
'reacted to your note': '╪к┘Б╪з╪╣┘Д ┘Е╪╣ ┘Е┘Д╪з╪н╪╕╪к┘Г', 'reacted to your note': '╪к┘Б╪з╪╣┘Д ┘Е╪╣ ┘Е┘Д╪з╪н╪╕╪к┘Г',
'reposted your note': '╪г╪╣╪з╪п ┘Ж╪┤╪▒ ┘Е┘Д╪з╪н╪╕╪к┘Г', 'reposted your note': '╪г╪╣╪з╪п ┘Ж╪┤╪▒ ┘Е┘Д╪з╪н╪╕╪к┘Г',
'zapped your note': '╪▓╪з╪и ┘Е┘Д╪з╪н╪╕╪к┘Г', 'zapped your note': '╪▓╪з╪и ┘Е┘Д╪з╪н╪╕╪к┘Г',
'highlighted your note': '╪г╪и╪▒╪▓ ┘Е┘Д╪з╪н╪╕╪к┘Г',
'zapped you': '╪▓╪з╪и┘Г', 'zapped you': '╪▓╪з╪и┘Г',
'Mark as read': '╪к╪╣┘Д┘К┘Е ┘Г┘Е┘В╪▒┘И╪б', 'Mark as read': '╪к╪╣┘Д┘К┘Е ┘Г┘Е┘В╪▒┘И╪б',
Report: '╪к╪и┘Д┘К╪║', Report: '╪к╪и┘Д┘К╪║',
@@ -488,14 +489,14 @@ export default {
Remote: '╪╣┘Ж ╪и┘П╪╣╪п', Remote: '╪╣┘Ж ╪и┘П╪╣╪п',
'Encrypted Key': '┘Е┘Б╪к╪з╪н ┘Е╪┤┘Б╪▒', 'Encrypted Key': '┘Е┘Б╪к╪з╪н ┘Е╪┤┘Б╪▒',
'Private Key': '┘Е┘Б╪к╪з╪н ╪о╪з╪╡', 'Private Key': '┘Е┘Б╪к╪з╪н ╪о╪з╪╡',
'Welcome to Jumble': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Jumble', 'Welcome to Smesh': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Smesh',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble ┘З┘И ╪╣┘Е┘К┘Д ┘К╪▒┘Г╪▓ ╪╣┘Д┘Й ╪к╪╡┘Б╪н ╪з┘Д┘Е╪▒╪н┘Д╪з╪к. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к ╪з┘Д┘Е╪л┘К╪▒╪й ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ╪г┘И ┘В┘Е ╪и╪к╪│╪м┘К┘Д ╪з┘Д╪п╪о┘И┘Д ┘Д╪╣╪▒╪╢ ╪о┘Д╪з╪╡╪к┘Г.', 'Smesh ┘З┘И ╪╣┘Е┘К┘Д ┘К╪▒┘Г╪▓ ╪╣┘Д┘Й ╪к╪╡┘Б╪н ╪з┘Д┘Е╪▒╪н┘Д╪з╪к. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к ╪з┘Д┘Е╪л┘К╪▒╪й ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ╪г┘И ┘В┘Е ╪и╪к╪│╪м┘К┘Д ╪з┘Д╪п╪о┘И┘Д ┘Д╪╣╪▒╪╢ ╪о┘Д╪з╪╡╪к┘Г.',
'Explore Relays': '╪з╪│╪к┘Г╪┤┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к', 'Explore Relays': '╪з╪│╪к┘Г╪┤┘Б ╪з┘Д┘Е╪▒╪н┘Д╪з╪к',
'Choose a feed': '╪з╪о╪к╪▒ ╪о┘Д╪з╪╡╪й', 'Choose a feed': '╪з╪о╪к╪▒ ╪о┘Д╪з╪╡╪й',
'and {{x}} others': '┘И {{x}} ╪в╪о╪▒┘И┘Ж', 'and {{x}} others': '┘И {{x}} ╪в╪о╪▒┘И┘Ж',
selfZapWarning: selfZapWarning:
'Jumble ╪║┘К╪▒ ┘Е╪│╪д┘И┘Д╪й ╪╣┘Е╪з ┘К╪н╪п╪л ╪е╪░╪з ╪г╪▒╪│┘Д╪к zap ┘Д┘Ж┘Б╪│┘Г. ╪к╪з╪и╪╣ ╪╣┘Д┘Й ┘Е╪│╪д┘И┘Д┘К╪к┘Г ╪з┘Д╪о╪з╪╡╪й. ЁЯШЙтЪб', 'Smesh ╪║┘К╪▒ ┘Е╪│╪д┘И┘Д╪й ╪╣┘Е╪з ┘К╪н╪п╪л ╪е╪░╪з ╪г╪▒╪│┘Д╪к zap ┘Д┘Ж┘Б╪│┘Г. ╪к╪з╪и╪╣ ╪╣┘Д┘Й ┘Е╪│╪д┘И┘Д┘К╪к┘Г ╪з┘Д╪о╪з╪╡╪й. ЁЯШЙтЪб',
'Emoji Pack': '╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й', 'Emoji Pack': '╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
'Emoji pack added': '╪к┘Е╪к ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й', 'Emoji pack added': '╪к┘Е╪к ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
'Add emoji pack failed': '┘Б╪┤┘Д ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й', 'Add emoji pack failed': '┘Б╪┤┘Д ╪е╪╢╪з┘Б╪й ╪н╪▓┘Е╪й ╪з┘Д╪▒┘Е┘И╪▓ ╪з┘Д╪к╪╣╪и┘К╪▒┘К╪й',
@@ -583,6 +584,56 @@ export default {
'Special Follow': '┘Е╪к╪з╪и╪╣╪й ╪о╪з╪╡╪й', 'Special Follow': '┘Е╪к╪з╪и╪╣╪й ╪о╪з╪╡╪й',
'Unfollow Special': '╪е┘Д╪║╪з╪б ╪з┘Д┘Е╪к╪з╪и╪╣╪й ╪з┘Д╪о╪з╪╡╪й', 'Unfollow Special': '╪е┘Д╪║╪з╪б ╪з┘Д┘Е╪к╪з╪и╪╣╪й ╪з┘Д╪о╪з╪╡╪й',
'Personal Feeds': '╪з┘Д╪к╪п┘Б┘В╪з╪к ╪з┘Д╪┤╪о╪╡┘К╪й', 'Personal Feeds': '╪з┘Д╪к╪п┘Б┘В╪з╪к ╪з┘Д╪┤╪о╪╡┘К╪й',
'Relay Feeds': '╪к╪п┘Б┘В╪з╪к ╪з┘Д╪к╪▒╪н┘К┘Д' 'Relay Feeds': '╪к╪п┘Б┘В╪з╪к ╪з┘Д╪к╪▒╪н┘К┘Д',
'Create Highlight': '╪е┘Ж╪┤╪з╪б ╪к┘Е┘К┘К╪▓',
'Write your thoughts about this highlight...': '╪з┘Г╪к╪и ╪г┘Б┘Г╪з╪▒┘Г ╪н┘И┘Д ┘З╪░╪з ╪з┘Д╪к┘Е┘К┘К╪▓...',
'Publish Highlight': '┘Ж╪┤╪▒ ╪з┘Д╪к┘Е┘К┘К╪▓',
'Show replies': '╪е╪╕┘З╪з╪▒ ╪з┘Д╪▒╪п┘И╪п',
'Hide replies': '╪е╪о┘Б╪з╪б ╪з┘Д╪▒╪п┘И╪п',
'Welcome to Smesh!': '┘Е╪▒╪н╪и┘Л╪з ╪и┘Г ┘Б┘К Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'╪о┘Д╪з╪╡╪к┘Г ┘Б╪з╪▒╪║╪й ┘Д╪г┘Ж┘Г ┘Д╪з ╪к╪к╪з╪и╪╣ ╪г┘К ╪┤╪о╪╡ ╪и╪╣╪п. ╪з╪и╪п╪г ╪и╪з╪│╪к┘Г╪┤╪з┘Б ┘Е╪н╪к┘И┘Й ┘Е╪л┘К╪▒ ┘Д┘Д╪з┘З╪к┘Е╪з┘Е ┘И┘Е╪к╪з╪и╪╣╪й ╪з┘Д┘Е╪│╪к╪о╪п┘Е┘К┘Ж ╪з┘Д╪░┘К┘Ж ╪к╪н╪и┘З┘Е!',
'Search Users': '╪з┘Д╪и╪н╪л ╪╣┘Ж ╪з┘Д┘Е╪│╪к╪о╪п┘Е┘К┘Ж',
'Create New Account': '╪е┘Ж╪┤╪з╪б ╪н╪│╪з╪и ╪м╪п┘К╪п',
Important: '┘Е┘З┘Е',
'Generate Your Account': '╪е┘Ж╪┤╪з╪б ╪н╪│╪з╪и┘Г',
'Your private key IS your account. Keep it safe!': '┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ╪з╪н╪к┘Б╪╕ ╪и┘З ╪и╪г┘Е╪з┘Ж!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'┘Б┘К Nostr╪М ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ╪е╪░╪з ┘Б┘В╪п╪к ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡╪М ╪│╪к┘Б┘В╪п ╪н╪│╪з╪и┘Г ╪е┘Д┘Й ╪з┘Д╪г╪и╪п.',
'Your Private Key': '┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡',
'Generate new key': '╪е┘Ж╪┤╪з╪б ┘Е┘Б╪к╪з╪н ╪м╪п┘К╪п',
'Download Backup File': '╪к┘Ж╪▓┘К┘Д ┘Е┘Д┘Б ╪з┘Д┘Ж╪│╪о ╪з┘Д╪з╪н╪к┘К╪з╪╖┘К',
'Copied to Clipboard': '╪к┘Е ╪з┘Д┘Ж╪│╪о ╪е┘Д┘Й ╪з┘Д╪н╪з┘Б╪╕╪й',
'Copy to Clipboard': '┘Ж╪│╪о ╪е┘Д┘Й ╪з┘Д╪н╪з┘Б╪╕╪й',
'I already saved my private key securely.': '┘Д┘В╪п ╪н┘Б╪╕╪к ┘Е┘Б╪к╪з╪н┘К ╪з┘Д╪о╪з╪╡ ╪и╪┤┘Г┘Д ╪в┘Е┘Ж ╪и╪з┘Д┘Б╪╣┘Д.',
'Almost Done!': '╪╣┘Д┘Й ┘И╪┤┘Г ╪з┘Д╪з┘Ж╪к┘З╪з╪б!',
'Set a password to encrypt your key, or skip to finish':
'┘В┘Е ╪и╪к╪╣┘К┘К┘Ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ┘Д╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г╪М ╪г┘И ╪к╪о╪╖┘Й ┘Д┘Д╪з┘Ж╪к┘З╪з╪б',
'Password Protection (Optional)': '╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (╪з╪о╪к┘К╪з╪▒┘К)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'┘К╪д╪п┘К ╪к╪╣┘К┘К┘Ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ╪е┘Д┘Й ╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘Б┘К ┘З╪░╪з ╪з┘Д┘Е╪к╪╡┘Б╪н. ┘К┘Е┘Г┘Ж┘Г ╪к╪о╪╖┘К ┘З╪░┘З ╪з┘Д╪о╪╖┘И╪й╪М ┘Д┘Г┘Ж┘Ж╪з ┘Ж┘И╪╡┘К ╪и╪к╪╣┘К┘К┘Ж ┘И╪з╪н╪п╪й ┘Д┘Е╪▓┘К╪п ┘Е┘Ж ╪з┘Д╪г┘Е╪з┘Ж.',
'Password (Optional)': '┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ (╪з╪о╪к┘К╪з╪▒┘К)',
'Enter password or leave empty to skip': '╪г╪п╪о┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ ╪г┘И ╪з╪к╪▒┘Г┘З╪з ┘Б╪з╪▒╪║╪й ┘Д┘Д╪к╪о╪╖┘К',
'Confirm Password': '╪к╪г┘Г┘К╪п ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒',
'Re-enter password': '╪г╪╣╪п ╪е╪п╪о╪з┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒',
'Passwords do not match': '┘Г┘Д┘Е╪з╪к ╪з┘Д┘Е╪▒┘И╪▒ ╪║┘К╪▒ ┘Е╪к╪╖╪з╪и┘В╪й',
'Finish Signup': '╪е┘Ж┘З╪з╪б ╪з┘Д╪к╪│╪м┘К┘Д',
// New improved signup copy
'Create Your Nostr Account': '╪г┘Ж╪┤╪ж ╪н╪│╪з╪и Nostr ╪з┘Д╪о╪з╪╡ ╪и┘Г',
'Generate your unique private key. This is your digital identity.':
'╪г┘Ж╪┤╪ж ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ╪з┘Д┘Б╪▒┘К╪п. ┘З╪░┘З ┘З┘К ┘З┘И┘К╪к┘Г ╪з┘Д╪▒┘В┘Е┘К╪й.',
'Critical: Save Your Private Key': '╪н╪▒╪м: ╪з╪н┘Б╪╕ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘З┘И ╪н╪│╪з╪и┘Г. ┘Д╪з ┘К┘И╪м╪п ╪з╪│╪к╪▒╪п╪з╪п ┘Д┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒. ╪е╪░╪з ┘Б┘В╪п╪к┘З╪М ╪│╪к┘Б┘В╪п ╪н╪│╪з╪и┘Г ┘Д┘Д╪г╪и╪п. ┘К╪▒╪м┘Й ╪н┘Б╪╕┘З ┘Б┘К ┘Е┘Г╪з┘Ж ╪в┘Е┘Ж.',
'I have safely backed up my private key': '┘Д┘В╪п ┘В┘Е╪к ╪и╪╣┘Е┘Д ┘Ж╪│╪о╪й ╪з╪н╪к┘К╪з╪╖┘К╪й ╪в┘Е┘Ж╪й ┘Д┘Е┘Б╪к╪з╪н┘К ╪з┘Д╪о╪з╪╡',
'Secure Your Account': '╪г┘Е┘С┘Ж ╪н╪│╪з╪и┘Г',
'Add an extra layer of protection with a password': '╪г╪╢┘Б ╪╖╪и┘В╪й ╪е╪╢╪з┘Б┘К╪й ┘Е┘Ж ╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒',
'Password Protection (Recommended)': '╪з┘Д╪н┘Е╪з┘К╪й ╪и┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (┘Е┘И╪╡┘Й ╪и┘З)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'╪г╪╢┘Б ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ ┘Д╪к╪┤┘Б┘К╪▒ ┘Е┘Б╪к╪з╪н┘Г ╪з┘Д╪о╪з╪╡ ┘Б┘К ┘З╪░╪з ╪з┘Д┘Е╪к╪╡┘Б╪н. ┘З╪░╪з ╪з╪о╪к┘К╪з╪▒┘К ┘Д┘Г┘Ж┘З ┘Е┘И╪╡┘Й ╪и┘З ╪и╪┤╪п╪й ┘Д╪г┘Е╪з┘Ж ╪г┘Б╪╢┘Д.',
'Create a password (or skip)': '╪г┘Ж╪┤╪ж ┘Г┘Д┘Е╪й ┘Е╪▒┘И╪▒ (╪г┘И ╪к╪о╪╖┘Й)',
'Enter your password again': '╪г╪п╪о┘Д ┘Г┘Д┘Е╪й ╪з┘Д┘Е╪▒┘И╪▒ ┘Е╪▒╪й ╪г╪о╪▒┘Й',
'Complete Signup': '╪е┘Г┘Е╪з┘Д ╪з┘Д╪к╪│╪м┘К┘Д',
Recommended: '┘Е┘И╪╡┘Й ╪и┘З'
} }
} }

View File

@@ -96,7 +96,7 @@ export default {
'Add an Account': 'Konto hinzuf├╝gen', 'Add an Account': 'Konto hinzuf├╝gen',
'More options': 'Mehr Optionen', 'More options': 'Mehr Optionen',
'Add client tag': 'Client-Tag hinzuf├╝gen', 'Add client tag': 'Client-Tag hinzuf├╝gen',
'Show others this was sent via Jumble': 'Anderen zeigen, dass dies ├╝ber Jumble gesendet wurde', 'Show others this was sent via Smesh': 'Anderen zeigen, dass dies ├╝ber Smesh gesendet wurde',
'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden m├╢chtest?', 'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden m├╢chtest?',
'relay sets': 'Relay-Sets', 'relay sets': 'Relay-Sets',
edit: 'bearbeiten', edit: 'bearbeiten',
@@ -199,9 +199,9 @@ export default {
All: 'Alle', All: 'Alle',
Reactions: 'Reaktionen', Reactions: 'Reaktionen',
Zaps: 'Zaps', Zaps: 'Zaps',
'Enjoying Jumble?': 'Gef├дllt dir Jumble?', 'Enjoying Smesh?': 'Gef├дllt dir Smesh?',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'Deine Spende hilft mir, Jumble zu pflegen und zu verbessern! ЁЯШК', 'Deine Spende hilft mir, Smesh zu pflegen und zu verbessern! ЁЯШК',
'Earlier notifications': 'Fr├╝here Benachrichtigungen', 'Earlier notifications': 'Fr├╝here Benachrichtigungen',
'Temporarily display this note': 'Notiz vor├╝bergehend anzeigen', 'Temporarily display this note': 'Notiz vor├╝bergehend anzeigen',
buttonFollowing: 'Folge', buttonFollowing: 'Folge',
@@ -257,7 +257,7 @@ export default {
Translation: '├Ьbersetzung', Translation: '├Ьbersetzung',
Balance: 'Guthaben', Balance: 'Guthaben',
characters: 'Zeichen', characters: 'Zeichen',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'Du kannst diesen API-Schl├╝ssel ├╝berall dort verwenden, wo LibreTranslate unterst├╝tzt wird. Die Service-URL ist {{serviceUrl}}', 'Du kannst diesen API-Schl├╝ssel ├╝berall dort verwenden, wo LibreTranslate unterst├╝tzt wird. Die Service-URL ist {{serviceUrl}}',
'Top up': 'Aufladen', 'Top up': 'Aufladen',
'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen', 'Will receive: {n} characters': 'Erhalte: {{n}} Zeichen',
@@ -393,6 +393,7 @@ export default {
'reacted to your note': 'hat auf Ihre Notiz reagiert', 'reacted to your note': 'hat auf Ihre Notiz reagiert',
'reposted your note': 'hat Ihre Notiz geteilt', 'reposted your note': 'hat Ihre Notiz geteilt',
'zapped your note': 'hat Ihre Notiz gezappt', 'zapped your note': 'hat Ihre Notiz gezappt',
'highlighted your note': 'hat Ihre Notiz hervorgehoben',
'zapped you': 'hat Sie gezappt', 'zapped you': 'hat Sie gezappt',
'Mark as read': 'Als gelesen markieren', 'Mark as read': 'Als gelesen markieren',
Report: 'Melden', Report: 'Melden',
@@ -502,14 +503,14 @@ export default {
Remote: 'Remote', Remote: 'Remote',
'Encrypted Key': 'Verschl├╝sselter Schl├╝ssel', 'Encrypted Key': 'Verschl├╝sselter Schl├╝ssel',
'Private Key': 'Privater Schl├╝ssel', 'Private Key': 'Privater Schl├╝ssel',
'Welcome to Jumble': 'Willkommen bei Jumble', 'Welcome to Smesh': 'Willkommen bei Smesh',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble ist ein Client, der sich auf das Durchsuchen von Relays konzentriert. Beginnen Sie mit der Erkundung interessanter Relays oder melden Sie sich an, um Ihren Following-Feed anzuzeigen.', 'Smesh ist ein Client, der sich auf das Durchsuchen von Relays konzentriert. Beginnen Sie mit der Erkundung interessanter Relays oder melden Sie sich an, um Ihren Following-Feed anzuzeigen.',
'Explore Relays': 'Relays erkunden', 'Explore Relays': 'Relays erkunden',
'Choose a feed': 'W├дhle einen Feed', 'Choose a feed': 'W├дhle einen Feed',
'and {{x}} others': 'und {{x}} andere', 'and {{x}} others': 'und {{x}} andere',
selfZapWarning: selfZapWarning:
'Jumble ist nicht verantwortlich f├╝r das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. ЁЯШЙтЪб', 'Smesh ist nicht verantwortlich f├╝r das, was passiert, wenn Sie sich selbst zappen. Fahren Sie auf eigene Gefahr fort. ЁЯШЙтЪб',
'Emoji Pack': 'Emoji-Paket', 'Emoji Pack': 'Emoji-Paket',
'Emoji pack added': 'Emoji-Paket hinzugef├╝gt', 'Emoji pack added': 'Emoji-Paket hinzugef├╝gt',
'Add emoji pack failed': 'Hinzuf├╝gen des Emoji-Pakets fehlgeschlagen', 'Add emoji pack failed': 'Hinzuf├╝gen des Emoji-Pakets fehlgeschlagen',
@@ -599,6 +600,61 @@ export default {
'Special Follow': 'Besonders Folgen', 'Special Follow': 'Besonders Folgen',
'Unfollow Special': 'Besonders Entfolgen', 'Unfollow Special': 'Besonders Entfolgen',
'Personal Feeds': 'Pers├╢nliche Feeds', 'Personal Feeds': 'Pers├╢nliche Feeds',
'Relay Feeds': 'Relay-Feeds' 'Relay Feeds': 'Relay-Feeds',
'Create Highlight': 'Markierung Erstellen',
'Write your thoughts about this highlight...':
'Schreiben Sie Ihre Gedanken zu dieser Markierung...',
'Publish Highlight': 'Markierung Ver├╢ffentlichen',
'Show replies': 'Antworten anzeigen',
'Hide replies': 'Antworten ausblenden',
'Welcome to Smesh!': 'Willkommen bei Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Ihr Feed ist leer, weil Sie noch niemandem folgen. Beginnen Sie damit, interessante Inhalte zu erkunden und Benutzern zu folgen, die Ihnen gefallen!',
'Search Users': 'Benutzer suchen',
'Create New Account': 'Neues Konto erstellen',
Important: 'Wichtig',
'Generate Your Account': 'Konto generieren',
'Your private key IS your account. Keep it safe!':
'Ihr privater Schl├╝ssel IST Ihr Konto. Bewahren Sie ihn sicher auf!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'In Nostr IST Ihr privater Schl├╝ssel Ihr Konto. Wenn Sie Ihren privaten Schl├╝ssel verlieren, verlieren Sie Ihr Konto f├╝r immer.',
'Your Private Key': 'Ihr privater Schl├╝ssel',
'Generate new key': 'Neuen Schl├╝ssel generieren',
'Download Backup File': 'Sicherungsdatei herunterladen',
'Copied to Clipboard': 'In Zwischenablage kopiert',
'Copy to Clipboard': 'In Zwischenablage kopieren',
'I already saved my private key securely.':
'Ich habe meinen privaten Schl├╝ssel bereits sicher gespeichert.',
'Almost Done!': 'Fast fertig!',
'Set a password to encrypt your key, or skip to finish':
'Legen Sie ein Passwort fest, um Ihren Schl├╝ssel zu verschl├╝sseln, oder ├╝berspringen Sie, um fertig zu werden',
'Password Protection (Optional)': 'Passwortschutz (optional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Das Festlegen eines Passworts verschl├╝sselt Ihren privaten Schl├╝ssel in diesem Browser. Sie k├╢nnen diesen Schritt ├╝berspringen, aber wir empfehlen, eines f├╝r zus├дtzliche Sicherheit festzulegen.',
'Password (Optional)': 'Passwort (optional)',
'Enter password or leave empty to skip':
'Passwort eingeben oder leer lassen, um zu ├╝berspringen',
'Confirm Password': 'Passwort best├дtigen',
'Re-enter password': 'Passwort erneut eingeben',
'Passwords do not match': 'Passw├╢rter stimmen nicht ├╝berein',
'Finish Signup': 'Registrierung abschlie├Яen',
// New improved signup copy
'Create Your Nostr Account': 'Erstellen Sie Ihr Nostr-Konto',
'Generate your unique private key. This is your digital identity.':
'Generieren Sie Ihren einzigartigen privaten Schl├╝ssel. Dies ist Ihre digitale Identit├дt.',
'Critical: Save Your Private Key': 'Kritisch: Speichern Sie Ihren privaten Schl├╝ssel',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Ihr privater Schl├╝ssel IST Ihr Konto. Es gibt keine Passwortwiederherstellung. Wenn Sie ihn verlieren, verlieren Sie Ihr Konto f├╝r immer. Bitte speichern Sie ihn an einem sicheren Ort.',
'I have safely backed up my private key': 'Ich habe meinen privaten Schl├╝ssel sicher gesichert',
'Secure Your Account': 'Sichern Sie Ihr Konto',
'Add an extra layer of protection with a password':
'F├╝gen Sie eine zus├дtzliche Schutzebene mit einem Passwort hinzu',
'Password Protection (Recommended)': 'Passwortschutz (empfohlen)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'F├╝gen Sie ein Passwort hinzu, um Ihren privaten Schl├╝ssel in diesem Browser zu verschl├╝sseln. Dies ist optional, aber f├╝r bessere Sicherheit dringend empfohlen.',
'Create a password (or skip)': 'Erstellen Sie ein Passwort (oder ├╝berspringen)',
'Enter your password again': 'Geben Sie Ihr Passwort erneut ein',
'Complete Signup': 'Registrierung abschlie├Яen',
Recommended: 'Empfohlen'
} }
} }

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'Add an Account', 'Add an Account': 'Add an Account',
'More options': 'More options', 'More options': 'More options',
'Add client tag': 'Add client tag', 'Add client tag': 'Add client tag',
'Show others this was sent via Jumble': 'Show others this was sent via Jumble', 'Show others this was sent via Smesh': 'Show others this was sent via Smesh',
'Are you sure you want to logout?': 'Are you sure you want to logout?', 'Are you sure you want to logout?': 'Are you sure you want to logout?',
'relay sets': 'relay sets', 'relay sets': 'relay sets',
edit: 'edit', edit: 'edit',
@@ -196,9 +196,9 @@ export default {
All: 'All', All: 'All',
Reactions: 'Reactions', Reactions: 'Reactions',
Zaps: 'Zaps', Zaps: 'Zaps',
'Enjoying Jumble?': 'Enjoying Jumble?', 'Enjoying Smesh?': 'Enjoying Smesh?',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'Your donation helps me maintain Jumble and make it better! ЁЯШК', 'Your donation helps me maintain Smesh and make it better! ЁЯШК',
'Earlier notifications': 'Earlier notifications', 'Earlier notifications': 'Earlier notifications',
'Temporarily display this note': 'Temporarily display this note', 'Temporarily display this note': 'Temporarily display this note',
buttonFollowing: 'Following', buttonFollowing: 'Following',
@@ -233,7 +233,6 @@ export default {
Preview: 'Preview', Preview: 'Preview',
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?': 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?':
'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?', 'You are about to publish an event signed by [{{eventAuthorName}}]. You are currently logged in as [{{currentUsername}}]. Are you sure?',
'Platinum Sponsors': 'Platinum Sponsors',
From: 'From', From: 'From',
'Comment on': 'Comment on', 'Comment on': 'Comment on',
'View on njump.me': 'View on njump.me', 'View on njump.me': 'View on njump.me',
@@ -247,23 +246,6 @@ export default {
'Lightning Invoice': 'Lightning Invoice', 'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed', 'Bookmark failed': 'Bookmark failed',
'Remove bookmark failed': 'Remove bookmark failed', 'Remove bookmark failed': 'Remove bookmark failed',
Translation: 'Translation',
Balance: 'Balance',
characters: 'characters',
jumbleTranslateApiKeyDescription:
'You can use this API key anywhere else that supports LibreTranslate. The service URL is {{serviceUrl}}',
'Top up': 'Top up',
'Will receive: {n} characters': 'Will receive: {{n}} characters',
'Top up {n} sats': 'Top up {{n}} sats',
'Minimum top up is {n} sats': 'Minimum top up is {{n}} sats',
Service: 'Service',
'Reset API key': 'Reset API key',
'Are you sure you want to reset your API key? This action cannot be undone.':
'Are you sure you want to reset your API key? This action cannot be undone.',
Warning: 'Warning',
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.':
'Your current API key will become invalid immediately, and any applications using it will stop working until you update them with the new key.',
'Service address': 'Service address',
Pay: 'Pay', Pay: 'Pay',
interactions: 'interactions', interactions: 'interactions',
notifications: 'notifications', notifications: 'notifications',
@@ -280,11 +262,10 @@ export default {
Continue: 'Continue', Continue: 'Continue',
'Successfully updated mute list': 'Successfully updated mute list', 'Successfully updated mute list': 'Successfully updated mute list',
'No pubkeys found from {url}': 'No pubkeys found from {{url}}', 'No pubkeys found from {url}': 'No pubkeys found from {{url}}',
'Translating...': 'Translating...',
Translate: 'Translate',
'Show original': 'Show original',
Website: 'Website', Website: 'Website',
'Hide untrusted notes': 'Hide untrusted notes', 'Hide untrusted notes': 'Hide untrusted notes',
'Hide untrusted interactions': 'Hide untrusted interactions',
'Hide untrusted notifications': 'Hide untrusted notifications',
'Open in another client': 'Open in another client', 'Open in another client': 'Open in another client',
Community: 'Community', Community: 'Community',
Group: 'Group', Group: 'Group',
@@ -383,6 +364,7 @@ export default {
'reacted to your note': 'reacted to your note', 'reacted to your note': 'reacted to your note',
'reposted your note': 'reposted your note', 'reposted your note': 'reposted your note',
'zapped your note': 'zapped your note', 'zapped your note': 'zapped your note',
'highlighted your note': 'highlighted your note',
'zapped you': 'zapped you', 'zapped you': 'zapped you',
'Mark as read': 'Mark as read', 'Mark as read': 'Mark as read',
Report: 'Report', Report: 'Report',
@@ -446,6 +428,7 @@ export default {
'Connect to your Rizful Vault': 'Connect to your Rizful Vault', 'Connect to your Rizful Vault': 'Connect to your Rizful Vault',
'Paste your one-time code here': 'Paste your one-time code here', 'Paste your one-time code here': 'Paste your one-time code here',
Connect: 'Connect', Connect: 'Connect',
'Connect Wallet': 'Connect Wallet',
'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!', 'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!',
'Set up': 'Set up', 'Set up': 'Set up',
Pinned: 'Pinned', Pinned: 'Pinned',
@@ -488,14 +471,14 @@ export default {
Remote: 'Remote', Remote: 'Remote',
'Encrypted Key': 'Encrypted Key', 'Encrypted Key': 'Encrypted Key',
'Private Key': 'Private Key', 'Private Key': 'Private Key',
'Welcome to Jumble': 'Welcome to Jumble', 'Welcome to Smesh': 'Welcome to Smesh',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.', 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.',
'Explore Relays': 'Explore Relays', 'Explore Relays': 'Explore Relays',
'Choose a feed': 'Choose a feed', 'Choose a feed': 'Choose a feed',
'and {{x}} others': 'and {{x}} others', 'and {{x}} others': 'and {{x}} others',
selfZapWarning: selfZapWarning:
'Jumble is not responsible for what happens if you zap yourself. Proceed at your own risk. ЁЯШЙтЪб', 'Smesh is not responsible for what happens if you zap yourself. Proceed at your own risk. ЁЯШЙтЪб',
'Emoji Pack': 'Emoji Pack', 'Emoji Pack': 'Emoji Pack',
'Emoji pack added': 'Emoji pack added', 'Emoji pack added': 'Emoji pack added',
'Add emoji pack failed': 'Add emoji pack failed', 'Add emoji pack failed': 'Add emoji pack failed',
@@ -586,6 +569,58 @@ export default {
'Special Follow': 'Special Follow', 'Special Follow': 'Special Follow',
'Unfollow Special': 'Unfollow Special', 'Unfollow Special': 'Unfollow Special',
'Personal Feeds': 'Personal Feeds', 'Personal Feeds': 'Personal Feeds',
'Relay Feeds': 'Relay Feeds' 'Relay Feeds': 'Relay Feeds',
'Create Highlight': 'Create Highlight',
'Write your thoughts about this highlight...': 'Write your thoughts about this highlight...',
'Publish Highlight': 'Publish Highlight',
'Show replies': 'Show replies',
'Hide replies': 'Hide replies',
'Welcome to Smesh!': 'Welcome to Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!',
'Search Users': 'Search Users',
'Create New Account': 'Create New Account',
Important: 'Important',
'Generate Your Account': 'Generate Your Account',
'Your private key IS your account. Keep it safe!':
'Your private key IS your account. Keep it safe!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'In Nostr, your private key IS your account. If you lose your account forever.',
'Your Private Key': 'Your Private Key',
'Generate new key': 'Generate new key',
'Download Backup File': 'Download Backup File',
'Copied to Clipboard': 'Copied to Clipboard',
'Copy to Clipboard': 'Copy to Clipboard',
'I already saved my private key securely.': 'I already saved my private key securely.',
'Almost Done!': 'Almost Done!',
'Set a password to encrypt your key, or skip to finish':
'Set a password to encrypt your key, or skip to finish',
'Password Protection (Optional)': 'Password Protection (Optional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.',
'Password (Optional)': 'Password (Optional)',
'Enter password or leave empty to skip': 'Enter password or leave empty to skip',
'Confirm Password': 'Confirm Password',
'Re-enter password': 'Re-enter password',
'Passwords do not match': 'Passwords do not match',
'Finish Signup': 'Finish Signup',
// New improved signup copy
'Create Your Nostr Account': 'Create Your Nostr Account',
'Generate your unique private key. This is your digital identity.':
'Generate your unique private key. This is your digital identity.',
'Critical: Save Your Private Key': 'Critical: Save Your Private Key',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.',
'I have safely backed up my private key': 'I have safely backed up my private key',
'Secure Your Account': 'Secure Your Account',
'Add an extra layer of protection with a password':
'Add an extra layer of protection with a password',
'Password Protection (Recommended)': 'Password Protection (Recommended)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.',
'Create a password (or skip)': 'Create a password (or skip)',
'Enter your password again': 'Enter your password again',
'Complete Signup': 'Complete Signup',
Recommended: 'Recommended'
} }
} }

View File

@@ -96,7 +96,7 @@ export default {
'Add an Account': 'Agregar una cuenta', 'Add an Account': 'Agregar una cuenta',
'More options': 'M├бs opciones', 'More options': 'M├бs opciones',
'Add client tag': 'Agregar etiqueta de cliente', 'Add client tag': 'Agregar etiqueta de cliente',
'Show others this was sent via Jumble': 'Mostrar a otros que esto se envi├│ v├нa Jumble', 'Show others this was sent via Smesh': 'Mostrar a otros que esto se envi├│ v├нa Smesh',
'Are you sure you want to logout?': '┬┐Est├бs seguro de que deseas cerrar sesi├│n?', 'Are you sure you want to logout?': '┬┐Est├бs seguro de que deseas cerrar sesi├│n?',
'relay sets': 'conjuntos de rel├йs', 'relay sets': 'conjuntos de rel├йs',
edit: 'editar', edit: 'editar',
@@ -199,9 +199,9 @@ export default {
All: 'Todo', All: 'Todo',
Reactions: 'Reacciones', Reactions: 'Reacciones',
Zaps: 'Zaps', Zaps: 'Zaps',
'Enjoying Jumble?': '┬┐Te gusta Jumble?', 'Enjoying Smesh?': '┬┐Te gusta Smesh?',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'┬бTu donaci├│n me ayuda a mantener y mejorar Jumble! ЁЯШК', '┬бTu donaci├│n me ayuda a mantener y mejorar Smesh! ЁЯШК',
'Earlier notifications': 'Notificaciones anteriores', 'Earlier notifications': 'Notificaciones anteriores',
'Temporarily display this note': 'Mostrar esta nota temporalmente', 'Temporarily display this note': 'Mostrar esta nota temporalmente',
buttonFollowing: 'Siguiendo', buttonFollowing: 'Siguiendo',
@@ -255,7 +255,7 @@ export default {
Translation: 'Traducci├│n', Translation: 'Traducci├│n',
Balance: 'Saldo', Balance: 'Saldo',
characters: 'caracteres', characters: 'caracteres',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}', 'Puedes usar esta clave API en cualquier otro lugar que soporte LibreTranslate. La URL del servicio es {{serviceUrl}}',
'Top up': 'Recargar', 'Top up': 'Recargar',
'Will receive: {n} characters': 'Recibir├бs: {{n}} caracteres', 'Will receive: {n} characters': 'Recibir├бs: {{n}} caracteres',
@@ -389,6 +389,7 @@ export default {
'reacted to your note': 'reaccion├│ a tu nota', 'reacted to your note': 'reaccion├│ a tu nota',
'reposted your note': 'reposte├│ tu nota', 'reposted your note': 'reposte├│ tu nota',
'zapped your note': 'zappe├│ tu nota', 'zapped your note': 'zappe├│ tu nota',
'highlighted your note': 'destac├│ tu nota',
'zapped you': 'te zappe├│', 'zapped you': 'te zappe├│',
'Mark as read': 'Marcar como le├нdo', 'Mark as read': 'Marcar como le├нdo',
Report: 'Reportar', Report: 'Reportar',
@@ -496,14 +497,14 @@ export default {
Remote: 'Remoto', Remote: 'Remoto',
'Encrypted Key': 'Clave privada cifrada', 'Encrypted Key': 'Clave privada cifrada',
'Private Key': 'Clave privada', 'Private Key': 'Clave privada',
'Welcome to Jumble': 'Bienvenido a Jumble', 'Welcome to Smesh': 'Bienvenido a Smesh',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble es un cliente enfocado en explorar relays. Comienza explorando relays interesantes o inicia sesi├│n para ver tu feed de seguidos.', 'Smesh es un cliente enfocado en explorar relays. Comienza explorando relays interesantes o inicia sesi├│n para ver tu feed de seguidos.',
'Explore Relays': 'Explorar Relays', 'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Elige un feed', 'Choose a feed': 'Elige un feed',
'and {{x}} others': 'y {{x}} otros', 'and {{x}} others': 'y {{x}} otros',
selfZapWarning: selfZapWarning:
'Jumble no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. ЁЯШЙтЪб', 'Smesh no se hace responsable de lo que suceda si te zapeas a ti mismo. Procede bajo tu propio riesgo. ЁЯШЙтЪб',
'Emoji Pack': 'Paquete de Emojis', 'Emoji Pack': 'Paquete de Emojis',
'Emoji pack added': 'Paquete de emojis a├▒adido', 'Emoji pack added': 'Paquete de emojis a├▒adido',
'Add emoji pack failed': 'Error al a├▒adir paquete de emojis', 'Add emoji pack failed': 'Error al a├▒adir paquete de emojis',
@@ -595,6 +596,59 @@ export default {
'Special Follow': 'Seguir Especial', 'Special Follow': 'Seguir Especial',
'Unfollow Special': 'Dejar de Seguir Especial', 'Unfollow Special': 'Dejar de Seguir Especial',
'Personal Feeds': 'Feeds Personales', 'Personal Feeds': 'Feeds Personales',
'Relay Feeds': 'Feeds de Relays' 'Relay Feeds': 'Feeds de Relays',
'Create Highlight': 'Crear Resaltado',
'Write your thoughts about this highlight...':
'Escribe tus pensamientos sobre este resaltado...',
'Publish Highlight': 'Publicar Resaltado',
'Show replies': 'Mostrar respuestas',
'Hide replies': 'Ocultar respuestas',
'Welcome to Smesh!': '┬бBienvenido a Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Tu feed est├б vac├нo porque a├║n no sigues a nadie. ┬бComienza explorando contenido interesante y siguiendo a los usuarios que te gusten!',
'Search Users': 'Buscar Usuarios',
'Create New Account': 'Crear nueva cuenta',
Important: 'Importante',
'Generate Your Account': 'Generar tu cuenta',
'Your private key IS your account. Keep it safe!':
'┬бTu clave privada ES tu cuenta. Mantenla segura!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'En Nostr, tu clave privada ES tu cuenta. Si pierdes tu clave privada, pierdes tu cuenta para siempre.',
'Your Private Key': 'Tu clave privada',
'Generate new key': 'Generar nueva clave',
'Download Backup File': 'Descargar archivo de respaldo',
'Copied to Clipboard': 'Copiado al portapapeles',
'Copy to Clipboard': 'Copiar al portapapeles',
'I already saved my private key securely.': 'Ya guard├й mi clave privada de forma segura.',
'Almost Done!': '┬бCasi terminado!',
'Set a password to encrypt your key, or skip to finish':
'Establece una contrase├▒a para cifrar tu clave, o om├нtela para finalizar',
'Password Protection (Optional)': 'Protecci├│n con contrase├▒a (opcional)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'Establecer una contrase├▒a cifra tu clave privada en este navegador. Puedes omitir este paso, pero recomendamos establecer una para mayor seguridad.',
'Password (Optional)': 'Contrase├▒a (opcional)',
'Enter password or leave empty to skip': 'Ingresa una contrase├▒a o d├йjalo vac├нo para omitir',
'Confirm Password': 'Confirmar contrase├▒a',
'Re-enter password': 'Vuelve a ingresar la contrase├▒a',
'Passwords do not match': 'Las contrase├▒as no coinciden',
'Finish Signup': 'Finalizar registro',
// New improved signup copy
'Create Your Nostr Account': 'Crea tu cuenta de Nostr',
'Generate your unique private key. This is your digital identity.':
'Genera tu clave privada ├║nica. Esta es tu identidad digital.',
'Critical: Save Your Private Key': 'Cr├нtico: Guarda tu clave privada',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'Tu clave privada ES tu cuenta. No hay recuperaci├│n de contrase├▒a. Si la pierdes, perder├бs tu cuenta para siempre. Por favor, gu├бrdala en un lugar seguro.',
'I have safely backed up my private key': 'He respaldado mi clave privada de forma segura',
'Secure Your Account': 'Asegura tu cuenta',
'Add an extra layer of protection with a password':
'A├▒ade una capa adicional de protecci├│n con una contrase├▒a',
'Password Protection (Recommended)': 'Protecci├│n con contrase├▒a (recomendado)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'A├▒ade una contrase├▒a para cifrar tu clave privada en este navegador. Esto es opcional pero muy recomendado para mayor seguridad.',
'Create a password (or skip)': 'Crear una contrase├▒a (o saltar)',
'Enter your password again': 'Ingresa tu contrase├▒a nuevamente',
'Complete Signup': 'Completar registro',
Recommended: 'Recomendado'
} }
} }

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': '╪з┘Б╪▓┘И╪п┘Ж ╪н╪│╪з╪и', 'Add an Account': '╪з┘Б╪▓┘И╪п┘Ж ╪н╪│╪з╪и',
'More options': '┌п╪▓█М┘Ж┘ЗтАМ┘З╪з█М ╪и█М╪┤╪к╪▒', 'More options': '┌п╪▓█М┘Ж┘ЗтАМ┘З╪з█М ╪и█М╪┤╪к╪▒',
'Add client tag': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪▒┌Ж╪│╪и ┌й┘Д╪з█М┘Ж╪к', 'Add client tag': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪▒┌Ж╪│╪и ┌й┘Д╪з█М┘Ж╪к',
'Show others this was sent via Jumble': '╪и┘З ╪п█М┌п╪▒╪з┘Ж ┘Ж╪┤╪з┘Ж ╪п┘З█М╪п ┌й┘З ╪з╪▓ ╪╖╪▒█М┘В Jumble ╪з╪▒╪│╪з┘Д ╪┤╪п┘З', 'Show others this was sent via Smesh': '╪и┘З ╪п█М┌п╪▒╪з┘Ж ┘Ж╪┤╪з┘Ж ╪п┘З█М╪п ┌й┘З ╪з╪▓ ╪╖╪▒█М┘В Smesh ╪з╪▒╪│╪з┘Д ╪┤╪п┘З',
'Are you sure you want to logout?': '╪в█М╪з ┘Е╪╖┘Е╪ж┘Ж ┘З╪│╪к█М╪п ┌й┘З ┘Е█МтАМ╪о┘И╪з┘З█М╪п ╪о╪з╪▒╪м ╪┤┘И█М╪п╪Я', 'Are you sure you want to logout?': '╪в█М╪з ┘Е╪╖┘Е╪ж┘Ж ┘З╪│╪к█М╪п ┌й┘З ┘Е█МтАМ╪о┘И╪з┘З█М╪п ╪о╪з╪▒╪м ╪┤┘И█М╪п╪Я',
'relay sets': '┘Е╪м┘Е┘И╪╣┘ЗтАМ┘З╪з█М ╪▒┘Д┘З', 'relay sets': '┘Е╪м┘Е┘И╪╣┘ЗтАМ┘З╪з█М ╪▒┘Д┘З',
edit: '┘И█М╪▒╪з█М╪┤', edit: '┘И█М╪▒╪з█М╪┤',
@@ -197,9 +197,9 @@ export default {
All: '┘З┘Е┘З', All: '┘З┘Е┘З',
Reactions: '┘И╪з┌й┘Ж╪┤тАМ┘З╪з', Reactions: '┘И╪з┌й┘Ж╪┤тАМ┘З╪з',
Zaps: '╪▓┘╛тАМ┘З╪з', Zaps: '╪▓┘╛тАМ┘З╪з',
'Enjoying Jumble?': '╪з╪▓ Jumble ┘Д╪░╪к ┘Е█МтАМ╪и╪▒█М╪п╪Я', 'Enjoying Smesh?': '╪з╪▓ Smesh ┘Д╪░╪к ┘Е█МтАМ╪и╪▒█М╪п╪Я',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'┌й┘Е┌й ┘Е╪з┘Д█М ╪┤┘Е╪з ╪и┘З ┘Е┘Ж ╪п╪▒ ┘Ж┌п┘З╪п╪з╪▒█М Jumble ┘И ╪и┘З╪к╪▒ ┌й╪▒╪п┘Ж ╪в┘Ж ┌й┘Е┌й ┘Е█МтАМ┌й┘Ж╪п! ЁЯШК', '┌й┘Е┌й ┘Е╪з┘Д█М ╪┤┘Е╪з ╪и┘З ┘Е┘Ж ╪п╪▒ ┘Ж┌п┘З╪п╪з╪▒█М Smesh ┘И ╪и┘З╪к╪▒ ┌й╪▒╪п┘Ж ╪в┘Ж ┌й┘Е┌й ┘Е█МтАМ┌й┘Ж╪п! ЁЯШК',
'Earlier notifications': '╪з╪╣┘Д╪з┘ЖтАМ┘З╪з█М ┘В╪и┘Д█М', 'Earlier notifications': '╪з╪╣┘Д╪з┘ЖтАМ┘З╪з█М ┘В╪и┘Д█М',
'Temporarily display this note': '┘Ж┘Е╪з█М╪┤ ┘Е┘И┘В╪к ╪з█М┘Ж █М╪з╪п╪п╪з╪┤╪к', 'Temporarily display this note': '┘Ж┘Е╪з█М╪┤ ┘Е┘И┘В╪к ╪з█М┘Ж █М╪з╪п╪п╪з╪┤╪к',
buttonFollowing: '╪п┘Ж╪и╪з┘Д ┘Е█МтАМ┌й┘Ж┘Е', buttonFollowing: '╪п┘Ж╪и╪з┘Д ┘Е█МтАМ┌й┘Ж┘Е',
@@ -252,7 +252,7 @@ export default {
Translation: '╪к╪▒╪м┘Е┘З', Translation: '╪к╪▒╪м┘Е┘З',
Balance: '┘Е┘И╪м┘И╪п█М', Balance: '┘Е┘И╪м┘И╪п█М',
characters: '┌й╪з╪▒╪з┌й╪к╪▒', characters: '┌й╪з╪▒╪з┌й╪к╪▒',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з╪▓ ╪з█М┘Ж ┌й┘Д█М╪п API ╪п╪▒ ┘З╪▒ ╪м╪з█М ╪п█М┌п╪▒█М ┌й┘З ╪з╪▓ LibreTranslate ┘╛╪┤╪к█М╪и╪з┘Ж█М ┘Е█МтАМ┌й┘Ж╪п ╪з╪│╪к┘Б╪з╪п┘З ┌й┘Ж█М╪п. ╪в╪п╪▒╪│ ╪│╪▒┘И█М╪│ {{serviceUrl}} ╪з╪│╪к', '┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з╪▓ ╪з█М┘Ж ┌й┘Д█М╪п API ╪п╪▒ ┘З╪▒ ╪м╪з█М ╪п█М┌п╪▒█М ┌й┘З ╪з╪▓ LibreTranslate ┘╛╪┤╪к█М╪и╪з┘Ж█М ┘Е█МтАМ┌й┘Ж╪п ╪з╪│╪к┘Б╪з╪п┘З ┌й┘Ж█М╪п. ╪в╪п╪▒╪│ ╪│╪▒┘И█М╪│ {{serviceUrl}} ╪з╪│╪к',
'Top up': '╪┤╪з╪▒┌Ш', 'Top up': '╪┤╪з╪▒┌Ш',
'Will receive: {n} characters': '╪п╪▒█М╪з┘Б╪к ╪о┘И╪з┘З█М╪п ┌й╪▒╪п: {{n}} ┌й╪з╪▒╪з┌й╪к╪▒', 'Will receive: {n} characters': '╪п╪▒█М╪з┘Б╪к ╪о┘И╪з┘З█М╪п ┌й╪▒╪п: {{n}} ┌й╪з╪▒╪з┌й╪к╪▒',
@@ -385,6 +385,7 @@ export default {
'reacted to your note': '╪и┘З █М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ┘И╪з┌й┘Ж╪┤ ┘Ж╪┤╪з┘Ж ╪п╪з╪п', 'reacted to your note': '╪и┘З █М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ┘И╪з┌й┘Ж╪┤ ┘Ж╪┤╪з┘Ж ╪п╪з╪п',
'reposted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪з╪▓┘Ж╪┤╪▒ ┌й╪▒╪п', 'reposted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪з╪▓┘Ж╪┤╪▒ ┌й╪▒╪п',
'zapped your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п', 'zapped your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
'highlighted your note': '█М╪з╪п╪п╪з╪┤╪к ╪┤┘Е╪з ╪▒╪з ╪и╪▒╪м╪│╪к┘З ┌й╪▒╪п',
'zapped you': '╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п', 'zapped you': '╪┤┘Е╪з ╪▒╪з ╪▓┘╛ ┌й╪▒╪п',
'Mark as read': '╪╣┘Д╪з┘Е╪ктАМ┌п╪░╪з╪▒█М ╪и┘З ╪╣┘Ж┘И╪з┘Ж ╪о┘И╪з┘Ж╪п┘З ╪┤╪п┘З', 'Mark as read': '╪╣┘Д╪з┘Е╪ктАМ┌п╪░╪з╪▒█М ╪и┘З ╪╣┘Ж┘И╪з┘Ж ╪о┘И╪з┘Ж╪п┘З ╪┤╪п┘З',
Report: '┌п╪▓╪з╪▒╪┤', Report: '┌п╪▓╪з╪▒╪┤',
@@ -491,14 +492,14 @@ export default {
Remote: '╪з╪▓ ╪▒╪з┘З ╪п┘И╪▒', Remote: '╪з╪▓ ╪▒╪з┘З ╪п┘И╪▒',
'Encrypted Key': '╪▒┘Е╪▓┌п╪░╪з╪▒█М ╪┤╪п┘З ┌й┘Д█М╪п', 'Encrypted Key': '╪▒┘Е╪▓┌п╪░╪з╪▒█М ╪┤╪п┘З ┌й┘Д█М╪п',
'Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М', 'Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М',
'Welcome to Jumble': '╪и┘З Jumble ╪о┘И╪┤ ╪в┘Е╪п█М╪п', 'Welcome to Smesh': '╪и┘З Smesh ╪о┘И╪┤ ╪в┘Е╪п█М╪п',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble █М┌й ┌й┘Д╪з█М┘Ж╪к ┘Е╪к┘Е╪▒┌й╪▓ ╪и╪▒ ┘Е╪▒┘И╪▒ ╪▒┘Д┘ЗтАМ┘З╪з╪│╪к. ╪и╪з ┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з█М ╪м╪з┘Д╪и ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п █М╪з ┘И╪з╪▒╪п ╪┤┘И█М╪п ╪к╪з ┘Б█М╪п ╪п┘Ж╪и╪з┘ДтАМ┌й┘Ж┘Ж╪п┘ЗтАМ┘З╪з█М ╪о┘И╪п ╪▒╪з ┘Е╪┤╪з┘З╪п┘З ┌й┘Ж█М╪п.', 'Smesh █М┌й ┌й┘Д╪з█М┘Ж╪к ┘Е╪к┘Е╪▒┌й╪▓ ╪и╪▒ ┘Е╪▒┘И╪▒ ╪▒┘Д┘ЗтАМ┘З╪з╪│╪к. ╪и╪з ┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з█М ╪м╪з┘Д╪и ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п █М╪з ┘И╪з╪▒╪п ╪┤┘И█М╪п ╪к╪з ┘Б█М╪п ╪п┘Ж╪и╪з┘ДтАМ┌й┘Ж┘Ж╪п┘ЗтАМ┘З╪з█М ╪о┘И╪п ╪▒╪з ┘Е╪┤╪з┘З╪п┘З ┌й┘Ж█М╪п.',
'Explore Relays': '┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з', 'Explore Relays': '┌й╪з┘И╪┤ ╪п╪▒ ╪▒┘Д┘ЗтАМ┘З╪з',
'Choose a feed': '█М┌й ┘Б█М╪п ╪з┘Ж╪к╪о╪з╪и ┌й┘Ж█М╪п', 'Choose a feed': '█М┌й ┘Б█М╪п ╪з┘Ж╪к╪о╪з╪и ┌й┘Ж█М╪п',
'and {{x}} others': '┘И {{x}} ╪п█М┌п╪▒', 'and {{x}} others': '┘И {{x}} ╪п█М┌п╪▒',
selfZapWarning: selfZapWarning:
'Jumble ┘Е╪│╪ж┘И┘Д█М╪к█М ╪п╪▒ ┘В╪и╪з┘Д ╪з╪к┘Б╪з┘В╪з╪к█М ┌й┘З ╪п╪▒ ╪╡┘И╪▒╪к ╪з╪▒╪│╪з┘Д zap ╪и┘З ╪о┘И╪п╪к╪з┘Ж ┘Е█МтАМ╪з┘Б╪к╪п ┘Ж╪п╪з╪▒╪п. ╪и╪з ┘Е╪│╪ж┘И┘Д█М╪к ╪о┘И╪п ╪з╪п╪з┘Е┘З ╪п┘З█М╪п. ЁЯШЙтЪб', 'Smesh ┘Е╪│╪ж┘И┘Д█М╪к█М ╪п╪▒ ┘В╪и╪з┘Д ╪з╪к┘Б╪з┘В╪з╪к█М ┌й┘З ╪п╪▒ ╪╡┘И╪▒╪к ╪з╪▒╪│╪з┘Д zap ╪и┘З ╪о┘И╪п╪к╪з┘Ж ┘Е█МтАМ╪з┘Б╪к╪п ┘Ж╪п╪з╪▒╪п. ╪и╪з ┘Е╪│╪ж┘И┘Д█М╪к ╪о┘И╪п ╪з╪п╪з┘Е┘З ╪п┘З█М╪п. ЁЯШЙтЪб',
'Emoji Pack': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М', 'Emoji Pack': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М',
'Emoji pack added': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ╪з╪╢╪з┘Б┘З ╪┤╪п', 'Emoji pack added': '╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ╪з╪╢╪з┘Б┘З ╪┤╪п',
'Add emoji pack failed': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ┘Ж╪з┘Е┘И┘Б┘В ╪и┘И╪п', 'Add emoji pack failed': '╪з┘Б╪▓┘И╪п┘Ж ╪и╪│╪к┘З ╪з█М┘Е┘И╪м█М ┘Ж╪з┘Е┘И┘Б┘В ╪и┘И╪п',
@@ -589,6 +590,60 @@ export default {
'Special Follow': '╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З', 'Special Follow': '╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
'Unfollow Special': '┘Д╪║┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З', 'Unfollow Special': '┘Д╪║┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┘И█М┌Ш┘З',
'Personal Feeds': '┘Б█М╪п┘З╪з█М ╪┤╪о╪╡█М', 'Personal Feeds': '┘Б█М╪п┘З╪з█М ╪┤╪о╪╡█М',
'Relay Feeds': '┘Б█М╪п┘З╪з█М ╪▒┘Д┘З' 'Relay Feeds': '┘Б█М╪п┘З╪з█М ╪▒┘Д┘З',
'Create Highlight': '╪з█М╪м╪з╪п ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М',
'Write your thoughts about this highlight...': '┘Ж╪╕╪▒╪з╪к ╪о┘И╪п ╪▒╪з ╪п╪▒╪и╪з╪▒┘З ╪з█М┘Ж ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М ╪и┘Ж┘И█М╪│█М╪п...',
'Publish Highlight': '╪з┘Ж╪к╪┤╪з╪▒ ╪и╪▒╪м╪│╪к┘ЗтАМ╪│╪з╪▓█М',
'Show replies': '┘Ж┘Е╪з█М╪┤ ┘╛╪з╪│╪отАМ┘З╪з',
'Hide replies': '┘╛┘Ж┘З╪з┘Ж ┌й╪▒╪п┘Ж ┘╛╪з╪│╪отАМ┘З╪з',
'Welcome to Smesh!': '╪и┘З Smesh ╪о┘И╪┤ ╪в┘Е╪п█М╪п!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'┘Б█М╪п ╪┤┘Е╪з ╪о╪з┘Д█М ╪з╪│╪к ╪▓█М╪▒╪з ┘З┘Ж┘И╪▓ ┌й╪│█М ╪▒╪з ╪п┘Ж╪и╪з┘Д ┘Ж┘Е█МтАМ┌й┘Ж█М╪п. ╪и╪з ┌й╪з┘И╪┤ ┘Е╪н╪к┘И╪з█М ╪м╪з┘Д╪и ┘И ╪п┘Ж╪и╪з┘Д ┌й╪▒╪п┘Ж ┌й╪з╪▒╪и╪▒╪з┘Ж█М ┌й┘З ╪п┘И╪│╪к ╪п╪з╪▒█М╪п ╪┤╪▒┘И╪╣ ┌й┘Ж█М╪п!',
'Search Users': '╪м╪│╪к╪м┘И█М ┌й╪з╪▒╪и╪▒╪з┘Ж',
'Create New Account': '╪з█М╪м╪з╪п ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪м╪п█М╪п',
Important: '┘Е┘З┘Е',
'Generate Your Account': '╪з█М╪м╪з╪п ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М',
'Your private key IS your account. Keep it safe!':
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ┘З┘Е╪з┘Ж ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪┤┘Е╪з╪│╪к. ╪в┘Ж ╪▒╪з ╪з█М┘Е┘Ж ┘Ж┌п┘З ╪п╪з╪▒█М╪п!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'╪п╪▒ Nostr╪М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ┘З┘Е╪з┘Ж ╪н╪│╪з╪и ┌й╪з╪▒╪и╪▒█М ╪┤┘Е╪з╪│╪к. ╪з┌п╪▒ ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ┌п┘Е ┌й┘Ж█М╪п╪М ╪и╪▒╪з█М ┘З┘Е█М╪┤┘З ╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪з╪▓ ╪п╪│╪к ┘Е█МтАМ╪п┘З█М╪п.',
'Your Private Key': '┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з',
'Generate new key': '╪з█М╪м╪з╪п ┌й┘Д█М╪п ╪м╪п█М╪п',
'Download Backup File': '╪п╪з┘Ж┘Д┘И╪п ┘Б╪з█М┘Д ┘╛╪┤╪к█М╪и╪з┘Ж',
'Copied to Clipboard': '╪п╪▒ ┌й┘Д█М┘╛тАМ╪и┘И╪▒╪п ┌й┘╛█М ╪┤╪п',
'Copy to Clipboard': '┌й┘╛█М ╪п╪▒ ┌й┘Д█М┘╛тАМ╪и┘И╪▒╪п',
'I already saved my private key securely.':
'┘Е┘Ж ┘В╪и┘Д╪з┘Л ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ╪и┘З ╪╖┘И╪▒ ╪з█М┘Е┘Ж ╪░╪о█М╪▒┘З ┌й╪▒╪п┘ЗтАМ╪з┘Е.',
'Almost Done!': '╪к┘В╪▒█М╪и╪з┘Л ╪к┘Е╪з┘Е ╪┤╪п!',
'Set a password to encrypt your key, or skip to finish':
'█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪и╪▒╪з█М ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┌й┘Д█М╪п ╪о┘И╪п ╪к┘Ж╪╕█М┘Е ┌й┘Ж█М╪п╪М █М╪з ╪и╪▒╪з█М ┘╛╪з█М╪з┘Ж ╪п╪з╪п┘Ж ╪▒╪п ┌й┘Ж█М╪п',
'Password Protection (Optional)': '╪н┘Б╪з╪╕╪к ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪з╪о╪к█М╪з╪▒█М)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'╪к┘Ж╪╕█М┘Е ╪▒┘Е╪▓ ╪╣╪и┘И╪▒╪М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ╪▒╪з ╪п╪▒ ╪з█М┘Ж ┘Е╪▒┘И╪▒┌п╪▒ ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┘Е█МтАМ┌й┘Ж╪п. ┘Е█МтАМ╪к┘И╪з┘Ж█М╪п ╪з█М┘Ж ┘Е╪▒╪н┘Д┘З ╪▒╪з ╪▒╪п ┌й┘Ж█М╪п╪М ╪з┘Е╪з ┘Е╪з ╪и╪▒╪з█М ╪з┘Е┘Ж█М╪к ╪и█М╪┤╪к╪▒ ╪к┘И╪╡█М┘З ┘Е█МтАМ┌й┘Ж█М┘Е █М┌й█М ╪к┘Ж╪╕█М┘Е ┌й┘Ж█М╪п.',
'Password (Optional)': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪з╪о╪к█М╪з╪▒█М)',
'Enter password or leave empty to skip': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪▒╪з ┘И╪з╪▒╪п ┌й┘Ж█М╪п █М╪з ╪и╪▒╪з█М ╪▒╪п ┌й╪▒╪п┘Ж ╪о╪з┘Д█М ╪и┌п╪░╪з╪▒█М╪п',
'Confirm Password': '╪к╪г█М█М╪п ╪▒┘Е╪▓ ╪╣╪и┘И╪▒',
'Re-enter password': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪▒╪з ╪п┘И╪и╪з╪▒┘З ┘И╪з╪▒╪п ┌й┘Ж█М╪п',
'Passwords do not match': '╪▒┘Е╪▓┘З╪з█М ╪╣╪и┘И╪▒ ┘Е╪╖╪з╪и┘В╪к ┘Ж╪п╪з╪▒┘Ж╪п',
'Finish Signup': '┘╛╪з█М╪з┘Ж ╪л╪и╪ктАМ┘Ж╪з┘Е',
// New improved signup copy
'Create Your Nostr Account': '╪н╪│╪з╪и Nostr ╪о┘И╪п ╪▒╪з ╪з█М╪м╪з╪п ┌й┘Ж█М╪п',
'Generate your unique private key. This is your digital identity.':
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ┘Е┘Ж╪н╪╡╪▒ ╪и┘З ┘Б╪▒╪п ╪о┘И╪п ╪▒╪з ╪з█М╪м╪з╪п ┌й┘Ж█М╪п. ╪з█М┘Ж ┘З┘И█М╪к ╪п█М╪м█М╪к╪з┘Д ╪┤┘Е╪з╪│╪к.',
'Critical: Save Your Private Key': '╪н█М╪з╪к█М: ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪▒╪з ╪░╪о█М╪▒┘З ┌й┘Ж█М╪п',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪┤┘Е╪з ╪н╪│╪з╪и ╪┤┘Е╪з╪│╪к. ╪и╪з╪▓█М╪з╪и█М ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ┘И╪м┘И╪п ┘Ж╪п╪з╪▒╪п. ╪з┌п╪▒ ╪в┘Ж ╪▒╪з ┌п┘Е ┌й┘Ж█М╪п╪М ╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪и╪▒╪з█М ┘З┘Е█М╪┤┘З ╪з╪▓ ╪п╪│╪к ╪о┘И╪з┘З█М╪п ╪п╪з╪п. ┘Д╪╖┘Б╪з┘Л ╪в┘Ж ╪▒╪з ╪п╪▒ ┘Е┌й╪з┘Ж█М ╪з┘Е┘Ж ╪░╪о█М╪▒┘З ┌й┘Ж█М╪п.',
'I have safely backed up my private key':
'┘Е┘Ж ╪и┘З ╪╖┘И╪▒ ╪з█М┘Е┘Ж ╪з╪▓ ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ┘Ж╪│╪о┘З ┘╛╪┤╪к█М╪и╪з┘Ж ╪к┘З█М┘З ┌й╪▒╪п┘ЗтАМ╪з┘Е',
'Secure Your Account': '╪н╪│╪з╪и ╪о┘И╪п ╪▒╪з ╪з█М┘Е┘Ж ┌й┘Ж█М╪п',
'Add an extra layer of protection with a password':
'█М┌й ┘Д╪з█М┘З ╪н┘Б╪з╪╕╪к█М ╪з╪╢╪з┘Б█М ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪з╪╢╪з┘Б┘З ┌й┘Ж█М╪п',
'Password Protection (Recommended)': '╪н┘Б╪з╪╕╪к ╪и╪з ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ (╪к┘И╪╡█М┘З ╪┤╪п┘З)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪и╪▒╪з█М ╪▒┘Е╪▓┌п╪░╪з╪▒█М ┌й┘Д█М╪п ╪о╪╡┘И╪╡█М ╪о┘И╪п ╪п╪▒ ╪з█М┘Ж ┘Е╪▒┘И╪▒┌п╪▒ ╪з╪╢╪з┘Б┘З ┌й┘Ж█М╪п. ╪з█М┘Ж ╪з╪о╪к█М╪з╪▒█М ╪з╪│╪к ╪з┘Е╪з ╪и╪▒╪з█М ╪з┘Е┘Ж█М╪к ╪и┘З╪к╪▒ ╪и┘З ╪┤╪п╪к ╪к┘И╪╡█М┘З ┘Е█МтАМ╪┤┘И╪п.',
'Create a password (or skip)': '█М┌й ╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪з█М╪м╪з╪п ┌й┘Ж█М╪п (█М╪з ╪▒╪п ┌й┘Ж█М╪п)',
'Enter your password again': '╪▒┘Е╪▓ ╪╣╪и┘И╪▒ ╪о┘И╪п ╪▒╪з ╪п┘И╪и╪з╪▒┘З ┘И╪з╪▒╪п ┌й┘Ж█М╪п',
'Complete Signup': '╪к┌й┘Е█М┘Д ╪л╪и╪ктАМ┘Ж╪з┘Е',
Recommended: '╪к┘И╪╡█М┘З ╪┤╪п┘З'
} }
} }

View File

@@ -96,7 +96,7 @@ export default {
'Add an Account': 'Ajouter un compte', 'Add an Account': 'Ajouter un compte',
'More options': "Plus d'options", 'More options': "Plus d'options",
'Add client tag': 'Ajouter une ├йtiquette client', 'Add client tag': 'Ajouter une ├йtiquette client',
'Show others this was sent via Jumble': 'Montrer aux autres que cela a ├йt├й envoy├й via Jumble', 'Show others this was sent via Smesh': 'Montrer aux autres que cela a ├йt├й envoy├й via Smesh',
'Are you sure you want to logout?': '├Кtes-vous s├╗r de vouloir vous d├йconnecter ?', 'Are you sure you want to logout?': '├Кtes-vous s├╗r de vouloir vous d├йconnecter ?',
'relay sets': 'groupes de relais', 'relay sets': 'groupes de relais',
edit: 'modifier', edit: 'modifier',
@@ -198,9 +198,9 @@ export default {
All: 'Tous', All: 'Tous',
Reactions: 'R├йactions', Reactions: 'R├йactions',
Zaps: 'Zaps', Zaps: 'Zaps',
'Enjoying Jumble?': 'Vous appr├йciez Jumble ?', 'Enjoying Smesh?': 'Vous appr├йciez Smesh ?',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
"Votre don m'aide ├а maintenir Jumble et ├а l'am├йliorer ! ЁЯШК", "Votre don m'aide ├а maintenir Smesh et ├а l'am├йliorer ! ЁЯШК",
'Earlier notifications': 'Notifications ant├йrieures', 'Earlier notifications': 'Notifications ant├йrieures',
'Temporarily display this note': 'Afficher temporairement cette note', 'Temporarily display this note': 'Afficher temporairement cette note',
buttonFollowing: 'Suivi', buttonFollowing: 'Suivi',
@@ -255,7 +255,7 @@ export default {
Translation: 'Traduction', Translation: 'Traduction',
Balance: 'Solde', Balance: 'Solde',
characters: 'caract├иres', characters: 'caract├иres',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'Vous pouvez utiliser cette cl├й API ailleurs qui prend en charge LibreTranslate. LтАЩURL du service est {{serviceUrl}}', 'Vous pouvez utiliser cette cl├й API ailleurs qui prend en charge LibreTranslate. LтАЩURL du service est {{serviceUrl}}',
'Top up': 'Recharger', 'Top up': 'Recharger',
'Will receive: {n} characters': 'Vous recevrez : {{n}} caract├иres', 'Will receive: {n} characters': 'Vous recevrez : {{n}} caract├иres',
@@ -393,6 +393,7 @@ export default {
'reacted to your note': 'a r├йagi ├а votre note', 'reacted to your note': 'a r├йagi ├а votre note',
'reposted your note': 'a repartag├й votre note', 'reposted your note': 'a repartag├й votre note',
'zapped your note': 'a zapp├й votre note', 'zapped your note': 'a zapp├й votre note',
'highlighted your note': 'a mis en ├йvidence votre note',
'zapped you': 'vous a zapp├й', 'zapped you': 'vous a zapp├й',
'Mark as read': 'Marquer comme lu', 'Mark as read': 'Marquer comme lu',
Report: 'Signaler', Report: 'Signaler',
@@ -501,14 +502,14 @@ export default {
Remote: 'Distant', Remote: 'Distant',
'Encrypted Key': 'Cl├й chiffr├йe', 'Encrypted Key': 'Cl├й chiffr├йe',
'Private Key': 'Cl├й priv├йe', 'Private Key': 'Cl├й priv├йe',
'Welcome to Jumble': 'Bienvenue sur Jumble', 'Welcome to Smesh': 'Bienvenue sur Smesh',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
"Jumble est un client ax├й sur la navigation des relais. Commencez par explorer des relais int├йressants ou connectez-vous pour voir votre fil d'abonnements.", "Smesh est un client ax├й sur la navigation des relais. Commencez par explorer des relais int├йressants ou connectez-vous pour voir votre fil d'abonnements.",
'Explore Relays': 'Explorer les relais', 'Explore Relays': 'Explorer les relais',
'Choose a feed': 'Choisir un fil', 'Choose a feed': 'Choisir un fil',
'and {{x}} others': 'et {{x}} autres', 'and {{x}} others': 'et {{x}} autres',
selfZapWarning: selfZapWarning:
"Jumble n'est pas responsable de ce qui se passe si vous vous zappez vous-m├кme. Proc├йdez ├а vos risques et p├йrils. ЁЯШЙтЪб", "Smesh n'est pas responsable de ce qui se passe si vous vous zappez vous-m├кme. Proc├йdez ├а vos risques et p├йrils. ЁЯШЙтЪб",
'Emoji Pack': "Pack d'Emojis", 'Emoji Pack': "Pack d'Emojis",
'Emoji pack added': "Pack d'emojis ajout├й", 'Emoji pack added': "Pack d'emojis ajout├й",
'Add emoji pack failed': "├Йchec de l'ajout du pack d'emojis", 'Add emoji pack failed': "├Йchec de l'ajout du pack d'emojis",
@@ -598,6 +599,59 @@ export default {
'Special Follow': 'Suivre Sp├йcial', 'Special Follow': 'Suivre Sp├йcial',
'Unfollow Special': 'Ne Plus Suivre Sp├йcial', 'Unfollow Special': 'Ne Plus Suivre Sp├йcial',
'Personal Feeds': 'Flux Personnels', 'Personal Feeds': 'Flux Personnels',
'Relay Feeds': 'Flux de Relais' 'Relay Feeds': 'Flux de Relais',
'Create Highlight': 'Cr├йer un Surlignage',
'Write your thoughts about this highlight...': '├Йcrivez vos pens├йes sur ce surlignage...',
'Publish Highlight': 'Publier le Surlignage',
'Show replies': 'Afficher les r├йponses',
'Hide replies': 'Masquer les r├йponses',
'Welcome to Smesh!': 'Bienvenue sur Smesh !',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Votre flux est vide car vous ne suivez personne pour le moment. Commencez par explorer du contenu int├йressant et suivez les utilisateurs que vous aimez !',
'Search Users': 'Rechercher des utilisateurs',
'Create New Account': 'Cr├йer un nouveau compte',
Important: 'Important',
'Generate Your Account': 'G├йn├йrer votre compte',
'Your private key IS your account. Keep it safe!':
'Votre cl├й priv├йe EST votre compte. Gardez-la en s├йcurit├й !',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Dans Nostr, votre cl├й priv├йe EST votre compte. Si vous perdez votre cl├й priv├йe, vous perdez votre compte pour toujours.',
'Your Private Key': 'Votre cl├й priv├йe',
'Generate new key': 'G├йn├йrer une nouvelle cl├й',
'Download Backup File': 'T├йl├йcharger le fichier de sauvegarde',
'Copied to Clipboard': 'Copi├й dans le presse-papiers',
'Copy to Clipboard': 'Copier dans le presse-papiers',
'I already saved my private key securely.':
"J'ai d├йj├а sauvegard├й ma cl├й priv├йe en toute s├йcurit├й.",
'Almost Done!': 'Presque termin├й !',
'Set a password to encrypt your key, or skip to finish':
'D├йfinissez un mot de passe pour chiffrer votre cl├й, ou ignorez pour terminer',
'Password Protection (Optional)': 'Protection par mot de passe (facultatif)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
"D├йfinir un mot de passe chiffre votre cl├й priv├йe dans ce navigateur. Vous pouvez ignorer cette ├йtape, mais nous recommandons d'en d├йfinir un pour plus de s├йcurit├й.",
'Password (Optional)': 'Mot de passe (facultatif)',
'Enter password or leave empty to skip': 'Entrez un mot de passe ou laissez vide pour ignorer',
'Confirm Password': 'Confirmer le mot de passe',
'Re-enter password': 'Ressaisissez le mot de passe',
'Passwords do not match': 'Les mots de passe ne correspondent pas',
'Finish Signup': "Terminer l'inscription",
// New improved signup copy
'Create Your Nostr Account': 'Cr├йez votre compte Nostr',
'Generate your unique private key. This is your digital identity.':
"G├йn├йrez votre cl├й priv├йe unique. C'est votre identit├й num├йrique.",
'Critical: Save Your Private Key': 'Critique : Sauvegardez votre cl├й priv├йe',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
"Votre cl├й priv├йe EST votre compte. Il n'y a pas de r├йcup├йration de mot de passe. Si vous la perdez, vous perdrez votre compte pour toujours. Veuillez la sauvegarder dans un endroit s├йcuris├й.",
'I have safely backed up my private key': "J'ai sauvegard├й ma cl├й priv├йe en toute s├йcurit├й",
'Secure Your Account': 'S├йcurisez votre compte',
'Add an extra layer of protection with a password':
'Ajoutez une couche de protection suppl├йmentaire avec un mot de passe',
'Password Protection (Recommended)': 'Protection par mot de passe (recommand├й)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
"Ajoutez un mot de passe pour chiffrer votre cl├й priv├йe dans ce navigateur. C'est facultatif mais fortement recommand├й pour une meilleure s├йcurit├й.",
'Create a password (or skip)': 'Cr├йez un mot de passe (ou ignorez)',
'Enter your password again': 'Entrez ├а nouveau votre mot de passe',
'Complete Signup': "Terminer l'inscription",
Recommended: 'Recommand├й'
} }
} }

View File

@@ -95,8 +95,8 @@ export default {
'Add an Account': 'рдЕрдХрд╛рдЙрдВрдЯ рдЬреЛрдбрд╝реЗрдВ', 'Add an Account': 'рдЕрдХрд╛рдЙрдВрдЯ рдЬреЛрдбрд╝реЗрдВ',
'More options': 'рдЕрдзрд┐рдХ рд╡рд┐рдХрд▓реНрдк', 'More options': 'рдЕрдзрд┐рдХ рд╡рд┐рдХрд▓реНрдк',
'Add client tag': 'рдХреНрд▓рд╛рдЗрдВрдЯ рдЯреИрдЧ рдЬреЛрдбрд╝реЗрдВ', 'Add client tag': 'рдХреНрд▓рд╛рдЗрдВрдЯ рдЯреИрдЧ рдЬреЛрдбрд╝реЗрдВ',
'Show others this was sent via Jumble': 'Show others this was sent via Smesh':
'рджреВрд╕рд░реЛрдВ рдХреЛ рджрд┐рдЦрд╛рдПрдВ рдХрд┐ рдпрд╣ Jumble рдХреЗ рдорд╛рдзреНрдпрдо рд╕реЗ рднреЗрдЬрд╛ рдЧрдпрд╛ рдерд╛', 'рджреВрд╕рд░реЛрдВ рдХреЛ рджрд┐рдЦрд╛рдПрдВ рдХрд┐ рдпрд╣ Smesh рдХреЗ рдорд╛рдзреНрдпрдо рд╕реЗ рднреЗрдЬрд╛ рдЧрдпрд╛ рдерд╛',
'Are you sure you want to logout?': 'рдХреНрдпрд╛ рдЖрдк рд╡рд╛рдХрдИ рд▓реЙрдЧрдЖрдЙрдЯ рдХрд░рдирд╛ рдЪрд╛рд╣рддреЗ рд╣реИрдВ?', 'Are you sure you want to logout?': 'рдХреНрдпрд╛ рдЖрдк рд╡рд╛рдХрдИ рд▓реЙрдЧрдЖрдЙрдЯ рдХрд░рдирд╛ рдЪрд╛рд╣рддреЗ рд╣реИрдВ?',
'relay sets': 'рд░рд┐рд▓реЗ рд╕реЗрдЯ', 'relay sets': 'рд░рд┐рд▓реЗ рд╕реЗрдЯ',
edit: 'рд╕рдВрдкрд╛рджрд┐рдд рдХрд░реЗрдВ', edit: 'рд╕рдВрдкрд╛рджрд┐рдд рдХрд░реЗрдВ',
@@ -198,9 +198,9 @@ export default {
All: 'рд╕рднреА', All: 'рд╕рднреА',
Reactions: 'рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛рдПрдВ', Reactions: 'рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛рдПрдВ',
Zaps: 'рдЬреИрдкреНрд╕', Zaps: 'рдЬреИрдкреНрд╕',
'Enjoying Jumble?': 'Jumble рдХрд╛ рдЖрдирдВрдж рд▓реЗ рд░рд╣реЗ рд╣реИрдВ?', 'Enjoying Smesh?': 'Smesh рдХрд╛ рдЖрдирдВрдж рд▓реЗ рд░рд╣реЗ рд╣реИрдВ?',
'Your donation helps me maintain Jumble and make it better! ЁЯШК': 'Your donation helps me maintain Smesh and make it better! ЁЯШК':
'рдЖрдкрдХрд╛ рджрд╛рди рдореБрдЭреЗ Jumble рдХреЛ рдмрдирд╛рдП рд░рдЦрдиреЗ рдФрд░ рдЗрд╕реЗ рдмреЗрд╣рддрд░ рдмрдирд╛рдиреЗ рдореЗрдВ рдорджрдж рдХрд░рддрд╛ рд╣реИ! ЁЯШК', 'рдЖрдкрдХрд╛ рджрд╛рди рдореБрдЭреЗ Smesh рдХреЛ рдмрдирд╛рдП рд░рдЦрдиреЗ рдФрд░ рдЗрд╕реЗ рдмреЗрд╣рддрд░ рдмрдирд╛рдиреЗ рдореЗрдВ рдорджрдж рдХрд░рддрд╛ рд╣реИ! ЁЯШК',
'Earlier notifications': 'рдкреБрд░рд╛рдиреА рд╕реВрдЪрдирд╛рдПрдВ', 'Earlier notifications': 'рдкреБрд░рд╛рдиреА рд╕реВрдЪрдирд╛рдПрдВ',
'Temporarily display this note': 'рдЗрд╕ рдиреЛрдЯ рдХреЛ рдЕрд╕реНрдерд╛рдпреА рд░реВрдк рд╕реЗ рдкреНрд░рджрд░реНрд╢рд┐рдд рдХрд░реЗрдВ', 'Temporarily display this note': 'рдЗрд╕ рдиреЛрдЯ рдХреЛ рдЕрд╕реНрдерд╛рдпреА рд░реВрдк рд╕реЗ рдкреНрд░рджрд░реНрд╢рд┐рдд рдХрд░реЗрдВ',
buttonFollowing: 'рдлреЙрд▓реЛ рдХрд░ рд░рд╣реЗ рд╣реИрдВ', buttonFollowing: 'рдлреЙрд▓реЛ рдХрд░ рд░рд╣реЗ рд╣реИрдВ',
@@ -253,7 +253,7 @@ export default {
Translation: 'рдЕрдиреБрд╡рд╛рдж', Translation: 'рдЕрдиреБрд╡рд╛рдж',
Balance: 'рдмреИрд▓реЗрдВрд╕', Balance: 'рдмреИрд▓реЗрдВрд╕',
characters: 'рдЕрдХреНрд╖рд░', characters: 'рдЕрдХреНрд╖рд░',
jumbleTranslateApiKeyDescription: smeshTranslateApiKeyDescription:
'рдЖрдк рдЗрд╕ API рдХреА рдХреЛ рдХрд╣реАрдВ рднреА рдЙрдкрдпреЛрдЧ рдХрд░ рд╕рдХрддреЗ рд╣реИрдВ рдЬреЛ LibreTranslate рдХрд╛ рд╕рдорд░реНрдерди рдХрд░рддрд╛ рд╣реИред рд╕реЗрд╡рд╛ URL рд╣реИ {{serviceUrl}}', 'рдЖрдк рдЗрд╕ API рдХреА рдХреЛ рдХрд╣реАрдВ рднреА рдЙрдкрдпреЛрдЧ рдХрд░ рд╕рдХрддреЗ рд╣реИрдВ рдЬреЛ LibreTranslate рдХрд╛ рд╕рдорд░реНрдерди рдХрд░рддрд╛ рд╣реИред рд╕реЗрд╡рд╛ URL рд╣реИ {{serviceUrl}}',
'Top up': 'рдЯреЙрдк рдЕрдк', 'Top up': 'рдЯреЙрдк рдЕрдк',
'Will receive: {n} characters': 'рдкреНрд░рд╛рдкреНрдд рд╣реЛрдВрдЧреЗ: {{n}} рдЕрдХреНрд╖рд░', 'Will receive: {n} characters': 'рдкреНрд░рд╛рдкреНрдд рд╣реЛрдВрдЧреЗ: {{n}} рдЕрдХреНрд╖рд░',
@@ -388,6 +388,7 @@ export default {
'reacted to your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдкрд░ рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛ рджреА', 'reacted to your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдкрд░ рдкреНрд░рддрд┐рдХреНрд░рд┐рдпрд╛ рджреА',
'reposted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд░реАрдкреЛрд╕реНрдЯ рдХрд┐рдпрд╛', 'reposted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд░реАрдкреЛрд╕реНрдЯ рдХрд┐рдпрд╛',
'zapped your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рдЬреИрдк рдХрд┐рдпрд╛', 'zapped your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
'highlighted your note': 'рдиреЗ рдЖрдкрдХреЗ рдиреЛрдЯ рдХреЛ рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдХрд┐рдпрд╛',
'zapped you': 'рдиреЗ рдЖрдкрдХреЛ рдЬреИрдк рдХрд┐рдпрд╛', 'zapped you': 'рдиреЗ рдЖрдкрдХреЛ рдЬреИрдк рдХрд┐рдпрд╛',
'Mark as read': 'рдкрдврд╝рд╛ рд╣реБрдЖ рдорд╛рд░реНрдХ рдХрд░реЗрдВ', 'Mark as read': 'рдкрдврд╝рд╛ рд╣реБрдЖ рдорд╛рд░реНрдХ рдХрд░реЗрдВ',
Report: 'рд░рд┐рдкреЛрд░реНрдЯ рдХрд░реЗрдВ', Report: 'рд░рд┐рдкреЛрд░реНрдЯ рдХрд░реЗрдВ',
@@ -493,14 +494,14 @@ export default {
Remote: 'рд░рд┐рдореЛрдЯ', Remote: 'рд░рд┐рдореЛрдЯ',
'Encrypted Key': 'рдПрдиреНрдХреНрд░рд┐рдкреНрдЯреЗрдб рдХреА', 'Encrypted Key': 'рдПрдиреНрдХреНрд░рд┐рдкреНрдЯреЗрдб рдХреА',
'Private Key': 'рдкреНрд░рд╛рдЗрд╡реЗрдЯ рдХреА', 'Private Key': 'рдкреНрд░рд╛рдЗрд╡реЗрдЯ рдХреА',
'Welcome to Jumble': 'Jumble рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ', 'Welcome to Smesh': 'Smesh рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble рдПрдХ рдХреНрд▓рд╛рдЗрдВрдЯ рд╣реИ рдЬреЛ рд░рд┐рд▓реЗ рдмреНрд░рд╛рдЙрдЬрд╝ рдХрд░рдиреЗ рдкрд░ рдХреЗрдВрджреНрд░рд┐рдд рд╣реИред рд░реЛрдЪрдХ рд░рд┐рд▓реЗ рдХреА рдЦреЛрдЬ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ рдпрд╛ рдЕрдкрдиреА рдлрд╝реЙрд▓реЛрдЗрдВрдЧ рдлрд╝реАрдб рджреЗрдЦрдиреЗ рдХреЗ рд▓рд┐рдП рд▓реЙрдЧрд┐рди рдХрд░реЗрдВред', 'Smesh рдПрдХ рдХреНрд▓рд╛рдЗрдВрдЯ рд╣реИ рдЬреЛ рд░рд┐рд▓реЗ рдмреНрд░рд╛рдЙрдЬрд╝ рдХрд░рдиреЗ рдкрд░ рдХреЗрдВрджреНрд░рд┐рдд рд╣реИред рд░реЛрдЪрдХ рд░рд┐рд▓реЗ рдХреА рдЦреЛрдЬ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ рдпрд╛ рдЕрдкрдиреА рдлрд╝реЙрд▓реЛрдЗрдВрдЧ рдлрд╝реАрдб рджреЗрдЦрдиреЗ рдХреЗ рд▓рд┐рдП рд▓реЙрдЧрд┐рди рдХрд░реЗрдВред',
'Explore Relays': 'рд░рд┐рд▓реЗ рдПрдХреНрд╕рдкреНрд▓реЛрд░ рдХрд░реЗрдВ', 'Explore Relays': 'рд░рд┐рд▓реЗ рдПрдХреНрд╕рдкреНрд▓реЛрд░ рдХрд░реЗрдВ',
'Choose a feed': 'рдПрдХ рдлреАрдб рдЪреБрдиреЗрдВ', 'Choose a feed': 'рдПрдХ рдлреАрдб рдЪреБрдиреЗрдВ',
'and {{x}} others': 'рдФрд░ {{x}} рдЕрдиреНрдп', 'and {{x}} others': 'рдФрд░ {{x}} рдЕрдиреНрдп',
selfZapWarning: selfZapWarning:
'Jumble рдЖрдкрдХреЗ рджреНрд╡рд╛рд░рд╛ рд╕реНрд╡рдпрдВ рдХреЛ zap рдХрд░рдиреЗ рдкрд░ рдХреНрдпрд╛ рд╣реЛрддрд╛ рд╣реИ, рдЗрд╕рдХреЗ рд▓рд┐рдП рдЬрд┐рдореНрдореЗрджрд╛рд░ рдирд╣реАрдВ рд╣реИред рдЕрдкрдиреА рдЬреЛрдЦрд┐рдо рдкрд░ рдЖрдЧреЗ рдмрдврд╝реЗрдВред ЁЯШЙтЪб', 'Smesh рдЖрдкрдХреЗ рджреНрд╡рд╛рд░рд╛ рд╕реНрд╡рдпрдВ рдХреЛ zap рдХрд░рдиреЗ рдкрд░ рдХреНрдпрд╛ рд╣реЛрддрд╛ рд╣реИ, рдЗрд╕рдХреЗ рд▓рд┐рдП рдЬрд┐рдореНрдореЗрджрд╛рд░ рдирд╣реАрдВ рд╣реИред рдЕрдкрдиреА рдЬреЛрдЦрд┐рдо рдкрд░ рдЖрдЧреЗ рдмрдврд╝реЗрдВред ЁЯШЙтЪб',
'Emoji Pack': 'рдЗрдореЛрдЬреА рдкреИрдХ', 'Emoji Pack': 'рдЗрдореЛрдЬреА рдкреИрдХ',
'Emoji pack added': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рд╛ рдЧрдпрд╛', 'Emoji pack added': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рд╛ рдЧрдпрд╛',
'Add emoji pack failed': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рдирд╛ рд╡рд┐рдлрд▓ рд░рд╣рд╛', 'Add emoji pack failed': 'рдЗрдореЛрдЬреА рдкреИрдХ рдЬреЛрдбрд╝рдирд╛ рд╡рд┐рдлрд▓ рд░рд╣рд╛',
@@ -590,6 +591,60 @@ export default {
'Special Follow': 'рд╡рд┐рд╢реЗрд╖ рдлрд╝реЙрд▓реЛ', 'Special Follow': 'рд╡рд┐рд╢реЗрд╖ рдлрд╝реЙрд▓реЛ',
'Unfollow Special': 'рд╡рд┐рд╢реЗрд╖ рдЕрдирдлрд╝реЙрд▓реЛ', 'Unfollow Special': 'рд╡рд┐рд╢реЗрд╖ рдЕрдирдлрд╝реЙрд▓реЛ',
'Personal Feeds': 'рд╡реНрдпрдХреНрддрд┐рдЧрдд рдлрд╝реАрдб', 'Personal Feeds': 'рд╡реНрдпрдХреНрддрд┐рдЧрдд рдлрд╝реАрдб',
'Relay Feeds': 'рд░рд┐рд▓реЗ рдлрд╝реАрдб' 'Relay Feeds': 'рд░рд┐рд▓реЗ рдлрд╝реАрдб',
'Create Highlight': 'рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдмрдирд╛рдПрдВ',
'Write your thoughts about this highlight...': 'рдЗрд╕ рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдХреЗ рдмрд╛рд░реЗ рдореЗрдВ рдЕрдкрдиреЗ рд╡рд┐рдЪрд╛рд░ рд▓рд┐рдЦреЗрдВ...',
'Publish Highlight': 'рд╣рд╛рдЗрд▓рд╛рдЗрдЯ рдкреНрд░рдХрд╛рд╢рд┐рдд рдХрд░реЗрдВ',
'Show replies': 'рдЬрд╡рд╛рдм рджрд┐рдЦрд╛рдПрдВ',
'Hide replies': 'рдЬрд╡рд╛рдм рдЫреБрдкрд╛рдПрдВ',
'Welcome to Smesh!': 'Smesh рдореЗрдВ рдЖрдкрдХрд╛ рд╕реНрд╡рд╛рдЧрдд рд╣реИ!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'рдЖрдкрдХрд╛ рдлрд╝реАрдб рдЦрд╛рд▓реА рд╣реИ рдХреНрдпреЛрдВрдХрд┐ рдЖрдк рдЕрднреА рддрдХ рдХрд┐рд╕реА рдХреЛ рдлрд╝реЙрд▓реЛ рдирд╣реАрдВ рдХрд░ рд░рд╣реЗ рд╣реИрдВред рджрд┐рд▓рдЪрд╕реНрдк рд╕рд╛рдордЧреНрд░реА рдХрд╛ рдЕрдиреНрд╡реЗрд╖рдг рдХрд░рдХреЗ рдФрд░ рдЕрдкрдиреА рдкрд╕рдВрдж рдХреЗ рдЙрдкрдпреЛрдЧрдХрд░реНрддрд╛рдУрдВ рдХреЛ рдлрд╝реЙрд▓реЛ рдХрд░рдХреЗ рд╢реБрд░реВ рдХрд░реЗрдВ!',
'Search Users': 'рдЙрдкрдпреЛрдЧрдХрд░реНрддрд╛ рдЦреЛрдЬреЗрдВ',
'Create New Account': 'рдирдпрд╛ рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
Important: 'рдорд╣рддреНрд╡рдкреВрд░реНрдг',
'Generate Your Account': 'рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
'Your private key IS your account. Keep it safe!':
'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╣реА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдЗрд╕реЗ рд╕реБрд░рдХреНрд╖рд┐рдд рд░рдЦреЗрдВ!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Nostr рдореЗрдВ, рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╣реА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдпрджрд┐ рдЖрдк рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдЦреЛ рджреЗрддреЗ рд╣реИрдВ, рддреЛ рдЖрдк рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рд╣рдореЗрд╢рд╛ рдХреЗ рд▓рд┐рдП рдЦреЛ рджреЗрддреЗ рд╣реИрдВред',
'Your Private Key': 'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА',
'Generate new key': 'рдирдИ рдХреБрдВрдЬреА рдмрдирд╛рдПрдВ',
'Download Backup File': 'рдмреИрдХрдЕрдк рдлрд╝рд╛рдЗрд▓ рдбрд╛рдЙрдирд▓реЛрдб рдХрд░реЗрдВ',
'Copied to Clipboard': 'рдХреНрд▓рд┐рдкрдмреЛрд░реНрдб рдкрд░ рдХреЙрдкреА рдХрд┐рдпрд╛ рдЧрдпрд╛',
'Copy to Clipboard': 'рдХреНрд▓рд┐рдкрдмреЛрд░реНрдб рдкрд░ рдХреЙрдкреА рдХрд░реЗрдВ',
'I already saved my private key securely.':
'рдореИрдВрдиреЗ рдкрд╣рд▓реЗ рд╣реА рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рд░реВрдк рд╕реЗ рд╕рд╣реЗрдЬ рд▓рд┐рдпрд╛ рд╣реИред',
'Almost Done!': 'рд▓рдЧрднрдЧ рд╣реЛ рдЧрдпрд╛!',
'Set a password to encrypt your key, or skip to finish':
'рдЕрдкрдиреА рдХреБрдВрдЬреА рдХреЛ рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдкрд╛рд╕рд╡рд░реНрдб рд╕реЗрдЯ рдХрд░реЗрдВ, рдпрд╛ рд╕рдорд╛рдкреНрдд рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдЫреЛрдбрд╝реЗрдВ',
'Password Protection (Optional)': 'рдкрд╛рд╕рд╡рд░реНрдб рд╕реБрд░рдХреНрд╖рд╛ (рд╡реИрдХрд▓реНрдкрд┐рдХ)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'рдкрд╛рд╕рд╡рд░реНрдб рд╕реЗрдЯ рдХрд░рдиреЗ рд╕реЗ рдЗрд╕ рдмреНрд░рд╛рдЙрдЬрд╝рд░ рдореЗрдВ рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рд╣реЛ рдЬрд╛рддреА рд╣реИред рдЖрдк рдЗрд╕ рдЪрд░рдг рдХреЛ рдЫреЛрдбрд╝ рд╕рдХрддреЗ рд╣реИрдВ, рд▓реЗрдХрд┐рди рд╣рдо рдЕрддрд┐рд░рд┐рдХреНрдд рд╕реБрд░рдХреНрд╖рд╛ рдХреЗ рд▓рд┐рдП рдПрдХ рд╕реЗрдЯ рдХрд░рдиреЗ рдХреА рд╕рд▓рд╛рд╣ рджреЗрддреЗ рд╣реИрдВред',
'Password (Optional)': 'рдкрд╛рд╕рд╡рд░реНрдб (рд╡реИрдХрд▓реНрдкрд┐рдХ)',
'Enter password or leave empty to skip': 'рдкрд╛рд╕рд╡рд░реНрдб рджрд░реНрдЬ рдХрд░реЗрдВ рдпрд╛ рдЫреЛрдбрд╝рдиреЗ рдХреЗ рд▓рд┐рдП рдЦрд╛рд▓реА рдЫреЛрдбрд╝реЗрдВ',
'Confirm Password': 'рдкрд╛рд╕рд╡рд░реНрдб рдХреА рдкреБрд╖реНрдЯрд┐ рдХрд░реЗрдВ',
'Re-enter password': 'рдкрд╛рд╕рд╡рд░реНрдб рдлрд┐рд░ рд╕реЗ рджрд░реНрдЬ рдХрд░реЗрдВ',
'Passwords do not match': 'рдкрд╛рд╕рд╡рд░реНрдб рдореЗрд▓ рдирд╣реАрдВ рдЦрд╛рддреЗ',
'Finish Signup': 'рд╕рд╛рдЗрдирдЕрдк рд╕рдорд╛рдкреНрдд рдХрд░реЗрдВ',
// New improved signup copy
'Create Your Nostr Account': 'рдЕрдкрдирд╛ Nostr рдЦрд╛рддрд╛ рдмрдирд╛рдПрдВ',
'Generate your unique private key. This is your digital identity.':
'рдЕрдкрдиреА рдЕрджреНрд╡рд┐рддреАрдп рдирд┐рдЬреА рдХреБрдВрдЬреА рдЙрддреНрдкрдиреНрди рдХрд░реЗрдВред рдпрд╣ рдЖрдкрдХреА рдбрд┐рдЬрд┐рдЯрд▓ рдкрд╣рдЪрд╛рди рд╣реИред',
'Critical: Save Your Private Key': 'рдорд╣рддреНрд╡рдкреВрд░реНрдг: рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рд╕рд╣реЗрдЬреЗрдВ',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'рдЖрдкрдХреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдЖрдкрдХрд╛ рдЦрд╛рддрд╛ рд╣реИред рдХреЛрдИ рдкрд╛рд╕рд╡рд░реНрдб рдкреБрдирд░реНрдкреНрд░рд╛рдкреНрддрд┐ рдирд╣реАрдВ рд╣реИред рдпрджрд┐ рдЖрдк рдЗрд╕реЗ рдЦреЛ рджреЗрддреЗ рд╣реИрдВ, рддреЛ рдЖрдк рд╣рдореЗрд╢рд╛ рдХреЗ рд▓рд┐рдП рдЕрдкрдирд╛ рдЦрд╛рддрд╛ рдЦреЛ рджреЗрдВрдЧреЗред рдХреГрдкрдпрд╛ рдЗрд╕реЗ рд╕реБрд░рдХреНрд╖рд┐рдд рд╕реНрдерд╛рди рдкрд░ рд╕рд╣реЗрдЬреЗрдВред',
'I have safely backed up my private key':
'рдореИрдВрдиреЗ рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рд░реВрдк рд╕реЗ рдмреИрдХрдЕрдк рдХрд░ рд▓рд┐рдпрд╛ рд╣реИ',
'Secure Your Account': 'рдЕрдкрдиреЗ рдЦрд╛рддреЗ рдХреЛ рд╕реБрд░рдХреНрд╖рд┐рдд рдХрд░реЗрдВ',
'Add an extra layer of protection with a password':
'рдкрд╛рд╕рд╡рд░реНрдб рдХреЗ рд╕рд╛рде рд╕реБрд░рдХреНрд╖рд╛ рдХреА рдПрдХ рдЕрддрд┐рд░рд┐рдХреНрдд рдкрд░рдд рдЬреЛрдбрд╝реЗрдВ',
'Password Protection (Recommended)': 'рдкрд╛рд╕рд╡рд░реНрдб рд╕реБрд░рдХреНрд╖рд╛ (рдЕрдиреБрд╢рдВрд╕рд┐рдд)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'рдЗрд╕ рдмреНрд░рд╛рдЙрдЬрд╝рд░ рдореЗрдВ рдЕрдкрдиреА рдирд┐рдЬреА рдХреБрдВрдЬреА рдХреЛ рдПрдиреНрдХреНрд░рд┐рдкреНрдЯ рдХрд░рдиреЗ рдХреЗ рд▓рд┐рдП рдкрд╛рд╕рд╡рд░реНрдб рдЬреЛрдбрд╝реЗрдВред рдпрд╣ рд╡реИрдХрд▓реНрдкрд┐рдХ рд╣реИ рд▓реЗрдХрд┐рди рдмреЗрд╣рддрд░ рд╕реБрд░рдХреНрд╖рд╛ рдХреЗ рд▓рд┐рдП рджреГрдврд╝рддрд╛ рд╕реЗ рдЕрдиреБрд╢рдВрд╕рд┐рдд рд╣реИред',
'Create a password (or skip)': 'рдПрдХ рдкрд╛рд╕рд╡рд░реНрдб рдмрдирд╛рдПрдВ (рдпрд╛ рдЫреЛрдбрд╝реЗрдВ)',
'Enter your password again': 'рдЕрдкрдирд╛ рдкрд╛рд╕рд╡рд░реНрдб рдлрд┐рд░ рд╕реЗ рджрд░реНрдЬ рдХрд░реЗрдВ',
'Complete Signup': 'рд╕рд╛рдЗрдирдЕрдк рдкреВрд░реНрдг рдХрд░реЗрдВ',
Recommended: 'рдЕрдиреБрд╢рдВрд╕рд┐рдд'
} }
} }

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