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

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

View File

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

View File

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

View File

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

1239
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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">
Sorry for the inconvenience. If you don't mind helping, you can{' '}
<a
href="https://github.com/CodyTseng/jumble/issues/new"
href="https://git.mleku.dev/mleku/smesh/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
submit an issue on GitHub
submit an issue
</a>{' '}
with the error details, or{' '}
<a
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
href="https://smesh.mleku.dev/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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={{ url: emojiUrl, pubkey: notification.pubkey }}
alt={emojiName}
className="w-6 h-6 rounded-md"
classNames={{ errorPlaceholder: 'bg-transparent' }}
className="w-6 h-6"
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
errorPlaceholder={<Heart size={24} className="text-red-400" />}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9',
icon: 'h-9 w-9 shrink-0',
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-xl [&_svg]:size-5'
}
},

View File

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

View File

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

View File

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

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 th from './locales/th'
import zh from './locales/zh'
import zh_TW from './locales/zh-TW'
const languages = {
ar: { resource: ar, name: 'العربية' },
@@ -37,7 +38,8 @@ const languages = {
'pt-PT': { resource: pt_PT, name: 'Português (Portugal)' },
ru: { resource: ru, name: 'Русский' },
th: { resource: th, name: 'ไทย' },
zh: { resource: zh, name: '简体中文' }
zh: { resource: zh, name: '简体中文' },
'zh-TW': { resource: zh_TW, name: '繁體中文' }
} as const
export type TLanguage = keyof typeof languages
@@ -62,6 +64,10 @@ i18n
},
detection: {
convertDetectedLanguage: (lng) => {
console.log('Detected language:', lng)
if (lng.startsWith('zh')) {
return ['zh', 'zh-CN', 'zh-SG'].includes(lng) ? 'zh' : 'zh-TW'
}
const supported = supportedLanguages.find((supported) => lng.startsWith(supported))
return supported || 'en'
}
@@ -71,6 +77,7 @@ i18n
i18n.services.formatter?.add('date', (timestamp, lng) => {
switch (lng) {
case 'zh':
case 'zh-TW':
case 'ja':
return dayjs(timestamp).format('YYYY年MM月DD日')
case 'pl':

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'إضافة حساب',
'More options': 'المزيد من الخيارات',
'Add client tag': 'إضافة وسم العميل',
'Show others this was sent via Jumble': 'عرض أن هذه الرسالة أُرسلت عبر Jumble',
'Show others this was sent via Smesh': 'عرض أن هذه الرسالة أُرسلت عبر Smesh',
'Are you sure you want to logout?': 'هل أنت متأكد أنك تريد تسجيل الخروج؟',
'relay sets': 'مجموعات الريلاي',
edit: 'تعديل',
@@ -195,9 +195,9 @@ export default {
All: 'الكل',
Reactions: 'التفاعلات',
Zaps: 'Zaps',
'Enjoying Jumble?': 'هل تستمتع بـ Jumble؟',
'Your donation helps me maintain Jumble and make it better! 😊':
'تبرعك يساعد في صيانة Jumble وتحسينه! 😊',
'Enjoying Smesh?': 'هل تستمتع بـ Smesh؟',
'Your donation helps me maintain Smesh and make it better! 😊':
'تبرعك يساعد في صيانة Smesh وتحسينه! 😊',
'Earlier notifications': 'الإشعارات السابقة',
'Temporarily display this note': 'عرض هذه الملاحظة مؤقتاً',
buttonFollowing: 'جارٍ المتابعة',
@@ -250,7 +250,7 @@ export default {
Translation: 'الترجمة',
Balance: 'الرصيد',
characters: 'الحروف',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'يمكنك استخدام مفتاح API هذا في أي مكان آخر يدعم LibreTranslate. عنوان الخدمة هو {{serviceUrl}}',
'Top up': 'إعادة شحن',
'Will receive: {n} characters': 'ستتلقى: {{n}} حروف',
@@ -384,6 +384,7 @@ export default {
'reacted to your note': 'تفاعل مع ملاحظتك',
'reposted your note': 'أعاد نشر ملاحظتك',
'zapped your note': 'زاب ملاحظتك',
'highlighted your note': 'أبرز ملاحظتك',
'zapped you': 'زابك',
'Mark as read': 'تعليم كمقروء',
Report: 'تبليغ',
@@ -488,14 +489,14 @@ export default {
Remote: 'عن بُعد',
'Encrypted Key': 'مفتاح مشفر',
'Private Key': 'مفتاح خاص',
'Welcome to Jumble': 'مرحبًا بك في Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.',
'Welcome to Smesh': 'مرحبًا بك في Smesh',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.',
'Explore Relays': 'استكشف المرحلات',
'Choose a feed': 'اختر خلاصة',
'and {{x}} others': 'و {{x}} آخرون',
selfZapWarning:
'Jumble غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡',
'Smesh غير مسؤولة عما يحدث إذا أرسلت zap لنفسك. تابع على مسؤوليتك الخاصة. 😉⚡',
'Emoji Pack': 'حزمة الرموز التعبيرية',
'Emoji pack added': 'تمت إضافة حزمة الرموز التعبيرية',
'Add emoji pack failed': 'فشل إضافة حزمة الرموز التعبيرية',
@@ -583,6 +584,56 @@ export default {
'Special Follow': 'متابعة خاصة',
'Unfollow Special': 'إلغاء المتابعة الخاصة',
'Personal Feeds': 'التدفقات الشخصية',
'Relay Feeds': 'تدفقات الترحيل'
'Relay Feeds': 'تدفقات الترحيل',
'Create Highlight': 'إنشاء تمييز',
'Write your thoughts about this highlight...': 'اكتب أفكارك حول هذا التمييز...',
'Publish Highlight': 'نشر التمييز',
'Show replies': 'إظهار الردود',
'Hide replies': 'إخفاء الردود',
'Welcome to Smesh!': 'مرحبًا بك في Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'خلاصتك فارغة لأنك لا تتابع أي شخص بعد. ابدأ باستكشاف محتوى مثير للاهتمام ومتابعة المستخدمين الذين تحبهم!',
'Search Users': 'البحث عن المستخدمين',
'Create New Account': 'إنشاء حساب جديد',
Important: 'مهم',
'Generate Your Account': 'إنشاء حسابك',
'Your private key IS your account. Keep it safe!': 'مفتاحك الخاص هو حسابك. احتفظ به بأمان!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'في Nostr، مفتاحك الخاص هو حسابك. إذا فقدت مفتاحك الخاص، ستفقد حسابك إلى الأبد.',
'Your Private Key': 'مفتاحك الخاص',
'Generate new key': 'إنشاء مفتاح جديد',
'Download Backup File': 'تنزيل ملف النسخ الاحتياطي',
'Copied to Clipboard': 'تم النسخ إلى الحافظة',
'Copy to Clipboard': 'نسخ إلى الحافظة',
'I already saved my private key securely.': 'لقد حفظت مفتاحي الخاص بشكل آمن بالفعل.',
'Almost Done!': 'على وشك الانتهاء!',
'Set a password to encrypt your key, or skip to finish':
'قم بتعيين كلمة مرور لتشفير مفتاحك، أو تخطى للانتهاء',
'Password Protection (Optional)': 'الحماية بكلمة مرور (اختياري)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'يؤدي تعيين كلمة مرور إلى تشفير مفتاحك الخاص في هذا المتصفح. يمكنك تخطي هذه الخطوة، لكننا نوصي بتعيين واحدة لمزيد من الأمان.',
'Password (Optional)': 'كلمة المرور (اختياري)',
'Enter password or leave empty to skip': 'أدخل كلمة المرور أو اتركها فارغة للتخطي',
'Confirm Password': 'تأكيد كلمة المرور',
'Re-enter password': 'أعد إدخال كلمة المرور',
'Passwords do not match': 'كلمات المرور غير متطابقة',
'Finish Signup': 'إنهاء التسجيل',
// New improved signup copy
'Create Your Nostr Account': 'أنشئ حساب Nostr الخاص بك',
'Generate your unique private key. This is your digital identity.':
'أنشئ مفتاحك الخاص الفريد. هذه هي هويتك الرقمية.',
'Critical: Save Your Private Key': 'حرج: احفظ مفتاحك الخاص',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'مفتاحك الخاص هو حسابك. لا يوجد استرداد لكلمة المرور. إذا فقدته، ستفقد حسابك للأبد. يرجى حفظه في مكان آمن.',
'I have safely backed up my private key': 'لقد قمت بعمل نسخة احتياطية آمنة لمفتاحي الخاص',
'Secure Your Account': 'أمّن حسابك',
'Add an extra layer of protection with a password': 'أضف طبقة إضافية من الحماية بكلمة مرور',
'Password Protection (Recommended)': 'الحماية بكلمة مرور (موصى به)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'أضف كلمة مرور لتشفير مفتاحك الخاص في هذا المتصفح. هذا اختياري لكنه موصى به بشدة لأمان أفضل.',
'Create a password (or skip)': 'أنشئ كلمة مرور (أو تخطى)',
'Enter your password again': 'أدخل كلمة المرور مرة أخرى',
'Complete Signup': 'إكمال التسجيل',
Recommended: 'موصى به'
}
}

View File

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

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

View File

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

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'افزودن حساب',
'More options': 'گزینه‌های بیشتر',
'Add client tag': 'افزودن برچسب کلاینت',
'Show others this was sent via Jumble': 'به دیگران نشان دهید که از طریق Jumble ارسال شده',
'Show others this was sent via Smesh': 'به دیگران نشان دهید که از طریق Smesh ارسال شده',
'Are you sure you want to logout?': 'آیا مطمئن هستید که می‌خواهید خارج شوید؟',
'relay sets': 'مجموعه‌های رله',
edit: 'ویرایش',
@@ -197,9 +197,9 @@ export default {
All: 'همه',
Reactions: 'واکنش‌ها',
Zaps: 'زپ‌ها',
'Enjoying Jumble?': 'از Jumble لذت می‌برید؟',
'Your donation helps me maintain Jumble and make it better! 😊':
'کمک مالی شما به من در نگهداری Jumble و بهتر کردن آن کمک می‌کند! 😊',
'Enjoying Smesh?': 'از Smesh لذت می‌برید؟',
'Your donation helps me maintain Smesh and make it better! 😊':
'کمک مالی شما به من در نگهداری Smesh و بهتر کردن آن کمک می‌کند! 😊',
'Earlier notifications': 'اعلان‌های قبلی',
'Temporarily display this note': 'نمایش موقت این یادداشت',
buttonFollowing: 'دنبال می‌کنم',
@@ -252,7 +252,7 @@ export default {
Translation: 'ترجمه',
Balance: 'موجودی',
characters: 'کاراکتر',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'می‌توانید از این کلید API در هر جای دیگری که از LibreTranslate پشتیبانی می‌کند استفاده کنید. آدرس سرویس {{serviceUrl}} است',
'Top up': 'شارژ',
'Will receive: {n} characters': 'دریافت خواهید کرد: {{n}} کاراکتر',
@@ -385,6 +385,7 @@ export default {
'reacted to your note': 'به یادداشت شما واکنش نشان داد',
'reposted your note': 'یادداشت شما را بازنشر کرد',
'zapped your note': 'یادداشت شما را زپ کرد',
'highlighted your note': 'یادداشت شما را برجسته کرد',
'zapped you': 'شما را زپ کرد',
'Mark as read': 'علامت‌گذاری به عنوان خوانده شده',
Report: 'گزارش',
@@ -491,14 +492,14 @@ export default {
Remote: 'از راه دور',
'Encrypted Key': 'رمزگذاری شده کلید',
'Private Key': 'کلید خصوصی',
'Welcome to Jumble': 'به Jumble خوش آمدید',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble یک کلاینت متمرکز بر مرور رله‌هاست. با کاوش در رله‌های جالب شروع کنید یا وارد شوید تا فید دنبال‌کننده‌های خود را مشاهده کنید.',
'Welcome to Smesh': 'به Smesh خوش آمدید',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh یک کلاینت متمرکز بر مرور رله‌هاست. با کاوش در رله‌های جالب شروع کنید یا وارد شوید تا فید دنبال‌کننده‌های خود را مشاهده کنید.',
'Explore Relays': 'کاوش در رله‌ها',
'Choose a feed': 'یک فید انتخاب کنید',
'and {{x}} others': 'و {{x}} دیگر',
selfZapWarning:
'Jumble مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان می‌افتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡',
'Smesh مسئولیتی در قبال اتفاقاتی که در صورت ارسال zap به خودتان می‌افتد ندارد. با مسئولیت خود ادامه دهید. 😉⚡',
'Emoji Pack': 'بسته ایموجی',
'Emoji pack added': 'بسته ایموجی اضافه شد',
'Add emoji pack failed': 'افزودن بسته ایموجی ناموفق بود',
@@ -589,6 +590,60 @@ export default {
'Special Follow': 'دنبال کردن ویژه',
'Unfollow Special': 'لغو دنبال کردن ویژه',
'Personal Feeds': 'فیدهای شخصی',
'Relay Feeds': 'فیدهای رله'
'Relay Feeds': 'فیدهای رله',
'Create Highlight': 'ایجاد برجسته‌سازی',
'Write your thoughts about this highlight...': 'نظرات خود را درباره این برجسته‌سازی بنویسید...',
'Publish Highlight': 'انتشار برجسته‌سازی',
'Show replies': 'نمایش پاسخ‌ها',
'Hide replies': 'پنهان کردن پاسخ‌ها',
'Welcome to Smesh!': 'به Smesh خوش آمدید!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'فید شما خالی است زیرا هنوز کسی را دنبال نمی‌کنید. با کاوش محتوای جالب و دنبال کردن کاربرانی که دوست دارید شروع کنید!',
'Search Users': 'جستجوی کاربران',
'Create New Account': 'ایجاد حساب کاربری جدید',
Important: 'مهم',
'Generate Your Account': 'ایجاد حساب کاربری',
'Your private key IS your account. Keep it safe!':
'کلید خصوصی شما همان حساب کاربری شماست. آن را ایمن نگه دارید!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'در Nostr، کلید خصوصی شما همان حساب کاربری شماست. اگر کلید خصوصی خود را گم کنید، برای همیشه حساب خود را از دست می‌دهید.',
'Your Private Key': 'کلید خصوصی شما',
'Generate new key': 'ایجاد کلید جدید',
'Download Backup File': 'دانلود فایل پشتیبان',
'Copied to Clipboard': 'در کلیپ‌بورد کپی شد',
'Copy to Clipboard': 'کپی در کلیپ‌بورد',
'I already saved my private key securely.':
'من قبلاً کلید خصوصی خود را به طور ایمن ذخیره کرده‌ام.',
'Almost Done!': 'تقریباً تمام شد!',
'Set a password to encrypt your key, or skip to finish':
'یک رمز عبور برای رمزگذاری کلید خود تنظیم کنید، یا برای پایان دادن رد کنید',
'Password Protection (Optional)': 'حفاظت با رمز عبور (اختیاری)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'تنظیم رمز عبور، کلید خصوصی شما را در این مرورگر رمزگذاری می‌کند. می‌توانید این مرحله را رد کنید، اما ما برای امنیت بیشتر توصیه می‌کنیم یکی تنظیم کنید.',
'Password (Optional)': 'رمز عبور (اختیاری)',
'Enter password or leave empty to skip': 'رمز عبور را وارد کنید یا برای رد کردن خالی بگذارید',
'Confirm Password': 'تأیید رمز عبور',
'Re-enter password': 'رمز عبور را دوباره وارد کنید',
'Passwords do not match': 'رمزهای عبور مطابقت ندارند',
'Finish Signup': 'پایان ثبت‌نام',
// New improved signup copy
'Create Your Nostr Account': 'حساب Nostr خود را ایجاد کنید',
'Generate your unique private key. This is your digital identity.':
'کلید خصوصی منحصر به فرد خود را ایجاد کنید. این هویت دیجیتال شماست.',
'Critical: Save Your Private Key': 'حیاتی: کلید خصوصی خود را ذخیره کنید',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'کلید خصوصی شما حساب شماست. بازیابی رمز عبور وجود ندارد. اگر آن را گم کنید، حساب خود را برای همیشه از دست خواهید داد. لطفاً آن را در مکانی امن ذخیره کنید.',
'I have safely backed up my private key':
'من به طور ایمن از کلید خصوصی خود نسخه پشتیبان تهیه کرده‌ام',
'Secure Your Account': 'حساب خود را ایمن کنید',
'Add an extra layer of protection with a password':
'یک لایه حفاظتی اضافی با رمز عبور اضافه کنید',
'Password Protection (Recommended)': 'حفاظت با رمز عبور (توصیه شده)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'یک رمز عبور برای رمزگذاری کلید خصوصی خود در این مرورگر اضافه کنید. این اختیاری است اما برای امنیت بهتر به شدت توصیه می‌شود.',
'Create a password (or skip)': 'یک رمز عبور ایجاد کنید (یا رد کنید)',
'Enter your password again': 'رمز عبور خود را دوباره وارد کنید',
'Complete Signup': 'تکمیل ثبت‌نام',
Recommended: 'توصیه شده'
}
}

View File

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

View File

@@ -95,8 +95,8 @@ export default {
'Add an Account': 'अकाउंट जोड़ें',
'More options': 'अधिक विकल्प',
'Add client tag': 'क्लाइंट टैग जोड़ें',
'Show others this was sent via Jumble':
'दूसरों को दिखाएं कि यह Jumble के माध्यम से भेजा गया था',
'Show others this was sent via Smesh':
'दूसरों को दिखाएं कि यह Smesh के माध्यम से भेजा गया था',
'Are you sure you want to logout?': 'क्या आप वाकई लॉगआउट करना चाहते हैं?',
'relay sets': 'रिले सेट',
edit: 'संपादित करें',
@@ -198,9 +198,9 @@ export default {
All: 'सभी',
Reactions: 'प्रतिक्रियाएं',
Zaps: 'जैप्स',
'Enjoying Jumble?': 'Jumble का आनंद ले रहे हैं?',
'Your donation helps me maintain Jumble and make it better! 😊':
'आपका दान मुझे Jumble को बनाए रखने और इसे बेहतर बनाने में मदद करता है! 😊',
'Enjoying Smesh?': 'Smesh का आनंद ले रहे हैं?',
'Your donation helps me maintain Smesh and make it better! 😊':
'आपका दान मुझे Smesh को बनाए रखने और इसे बेहतर बनाने में मदद करता है! 😊',
'Earlier notifications': 'पुरानी सूचनाएं',
'Temporarily display this note': 'इस नोट को अस्थायी रूप से प्रदर्शित करें',
buttonFollowing: 'फॉलो कर रहे हैं',
@@ -253,7 +253,7 @@ export default {
Translation: 'अनुवाद',
Balance: 'बैलेंस',
characters: 'अक्षर',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'आप इस API की को कहीं भी उपयोग कर सकते हैं जो LibreTranslate का समर्थन करता है। सेवा URL है {{serviceUrl}}',
'Top up': 'टॉप अप',
'Will receive: {n} characters': 'प्राप्त होंगे: {{n}} अक्षर',
@@ -388,6 +388,7 @@ export default {
'reacted to your note': 'ने आपके नोट पर प्रतिक्रिया दी',
'reposted your note': 'ने आपके नोट को रीपोस्ट किया',
'zapped your note': 'ने आपके नोट को जैप किया',
'highlighted your note': 'ने आपके नोट को हाइलाइट किया',
'zapped you': 'ने आपको जैप किया',
'Mark as read': 'पढ़ा हुआ मार्क करें',
Report: 'रिपोर्ट करें',
@@ -493,14 +494,14 @@ export default {
Remote: 'रिमोट',
'Encrypted Key': 'एन्क्रिप्टेड की',
'Private Key': 'प्राइवेट की',
'Welcome to Jumble': 'Jumble में आपका स्वागत है',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।',
'Welcome to Smesh': 'Smesh में आपका स्वागत है',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।',
'Explore Relays': 'रिले एक्सप्लोर करें',
'Choose a feed': 'एक फीड चुनें',
'and {{x}} others': 'और {{x}} अन्य',
selfZapWarning:
'Jumble आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡',
'Smesh आपके द्वारा स्वयं को zap करने पर क्या होता है, इसके लिए जिम्मेदार नहीं है। अपनी जोखिम पर आगे बढ़ें। 😉⚡',
'Emoji Pack': 'इमोजी पैक',
'Emoji pack added': 'इमोजी पैक जोड़ा गया',
'Add emoji pack failed': 'इमोजी पैक जोड़ना विफल रहा',
@@ -590,6 +591,60 @@ export default {
'Special Follow': 'विशेष फ़ॉलो',
'Unfollow Special': 'विशेष अनफ़ॉलो',
'Personal Feeds': 'व्यक्तिगत फ़ीड',
'Relay Feeds': 'रिले फ़ीड'
'Relay Feeds': 'रिले फ़ीड',
'Create Highlight': 'हाइलाइट बनाएं',
'Write your thoughts about this highlight...': 'इस हाइलाइट के बारे में अपने विचार लिखें...',
'Publish Highlight': 'हाइलाइट प्रकाशित करें',
'Show replies': 'जवाब दिखाएं',
'Hide replies': 'जवाब छुपाएं',
'Welcome to Smesh!': 'Smesh में आपका स्वागत है!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'आपका फ़ीड खाली है क्योंकि आप अभी तक किसी को फ़ॉलो नहीं कर रहे हैं। दिलचस्प सामग्री का अन्वेषण करके और अपनी पसंद के उपयोगकर्ताओं को फ़ॉलो करके शुरू करें!',
'Search Users': 'उपयोगकर्ता खोजें',
'Create New Account': 'नया खाता बनाएं',
Important: 'महत्वपूर्ण',
'Generate Your Account': 'अपना खाता बनाएं',
'Your private key IS your account. Keep it safe!':
'आपकी निजी कुंजी ही आपका खाता है। इसे सुरक्षित रखें!',
'In Nostr, your private key IS your account. If you lose your private key, you lose your account forever.':
'Nostr में, आपकी निजी कुंजी ही आपका खाता है। यदि आप अपनी निजी कुंजी खो देते हैं, तो आप अपना खाता हमेशा के लिए खो देते हैं।',
'Your Private Key': 'आपकी निजी कुंजी',
'Generate new key': 'नई कुंजी बनाएं',
'Download Backup File': 'बैकअप फ़ाइल डाउनलोड करें',
'Copied to Clipboard': 'क्लिपबोर्ड पर कॉपी किया गया',
'Copy to Clipboard': 'क्लिपबोर्ड पर कॉपी करें',
'I already saved my private key securely.':
'मैंने पहले ही अपनी निजी कुंजी को सुरक्षित रूप से सहेज लिया है।',
'Almost Done!': 'लगभग हो गया!',
'Set a password to encrypt your key, or skip to finish':
'अपनी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड सेट करें, या समाप्त करने के लिए छोड़ें',
'Password Protection (Optional)': 'पासवर्ड सुरक्षा (वैकल्पिक)',
'Setting a password encrypts your private key in this browser. You can skip this step, but we recommend setting one for added security.':
'पासवर्ड सेट करने से इस ब्राउज़र में आपकी निजी कुंजी एन्क्रिप्ट हो जाती है। आप इस चरण को छोड़ सकते हैं, लेकिन हम अतिरिक्त सुरक्षा के लिए एक सेट करने की सलाह देते हैं।',
'Password (Optional)': 'पासवर्ड (वैकल्पिक)',
'Enter password or leave empty to skip': 'पासवर्ड दर्ज करें या छोड़ने के लिए खाली छोड़ें',
'Confirm Password': 'पासवर्ड की पुष्टि करें',
'Re-enter password': 'पासवर्ड फिर से दर्ज करें',
'Passwords do not match': 'पासवर्ड मेल नहीं खाते',
'Finish Signup': 'साइनअप समाप्त करें',
// New improved signup copy
'Create Your Nostr Account': 'अपना Nostr खाता बनाएं',
'Generate your unique private key. This is your digital identity.':
'अपनी अद्वितीय निजी कुंजी उत्पन्न करें। यह आपकी डिजिटल पहचान है।',
'Critical: Save Your Private Key': 'महत्वपूर्ण: अपनी निजी कुंजी सहेजें',
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
'आपकी निजी कुंजी आपका खाता है। कोई पासवर्ड पुनर्प्राप्ति नहीं है। यदि आप इसे खो देते हैं, तो आप हमेशा के लिए अपना खाता खो देंगे। कृपया इसे सुरक्षित स्थान पर सहेजें।',
'I have safely backed up my private key':
'मैंने अपनी निजी कुंजी को सुरक्षित रूप से बैकअप कर लिया है',
'Secure Your Account': 'अपने खाते को सुरक्षित करें',
'Add an extra layer of protection with a password':
'पासवर्ड के साथ सुरक्षा की एक अतिरिक्त परत जोड़ें',
'Password Protection (Recommended)': 'पासवर्ड सुरक्षा (अनुशंसित)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'इस ब्राउज़र में अपनी निजी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड जोड़ें। यह वैकल्पिक है लेकिन बेहतर सुरक्षा के लिए दृढ़ता से अनुशंसित है।',
'Create a password (or skip)': 'एक पासवर्ड बनाएं (या छोड़ें)',
'Enter your password again': 'अपना पासवर्ड फिर से दर्ज करें',
'Complete Signup': 'साइनअप पूर्ण करें',
Recommended: 'अनुशंसित'
}
}

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