21 Commits

Author SHA1 Message Date
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
91 changed files with 2622 additions and 1223 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,7 +147,7 @@ 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

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" />
<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>

890
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 .",

View File

@@ -15,7 +15,6 @@ 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'
@@ -43,14 +42,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>

File diff suppressed because one or more lines are too long

BIN
src/assets/smeshdark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
src/assets/smeshlight.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 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

@@ -110,8 +110,8 @@ export default function Signup({
</div>
</div>
<div className="w-full flex gap-2 items-center">
<Button onClick={handleDownload} className="w-full">
<div className="w-full flex flex-wrap gap-2">
<Button onClick={handleDownload} className="flex-1">
<Download />
{t('Download Backup File')}
</Button>
@@ -122,7 +122,7 @@ export default function Signup({
setTimeout(() => setCopied(false), 2000)
}}
variant="secondary"
className="w-full"
className="flex-1"
>
{copied ? <Check /> : <Copy />}
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}

View File

@@ -1,5 +1,5 @@
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'
@@ -14,9 +14,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">
{[
@@ -45,7 +45,7 @@ export default function Donation({ className }: { className?: string }) {
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={JUMBLE_PUBKEY}
pubkey={SMESH_PUBKEY}
defaultAmount={donationAmount}
/>
</div>

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

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

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

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

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

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

@@ -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)
@@ -143,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

@@ -11,8 +11,8 @@ import {
} 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'
@@ -42,7 +42,6 @@ export default function PostContent({
}) {
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)
@@ -157,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) {

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

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

@@ -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,12 +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'
@@ -40,7 +42,10 @@ export default function ReplyNote({
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) {
@@ -54,16 +59,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={cn(
'pb-3 transition-colors duration-500 clickable',
'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" />

View File

@@ -1,11 +1,11 @@
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 { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
@@ -16,7 +16,7 @@ import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { repliesMap } = useReply()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -27,7 +27,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
let parentKeys = [parentKey]
while (parentKeys.length > 0) {
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
events.forEach((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
@@ -35,11 +35,11 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = repliesMap.get(replyKey)
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.events.every((evt) => !isUserTrusted(evt.pubkey))
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return
}
@@ -53,7 +53,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [
parentKey,
repliesMap,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
@@ -81,7 +81,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
}, 1500)
}, [])
if (replies.length === 0) return <div className="border-b w-full" />
if (replies.length === 0) return null
return (
<div>
@@ -91,11 +91,16 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
className={cn(
'w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
!isExpanded && 'border-b'
)}
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" />
@@ -125,14 +130,14 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
<div
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
key={currentReplyKey}
className="scroll-mt-12 flex"
className="scroll-mt-12 flex relative"
>
<div className="w-3 flex-shrink-0 bg-border" />
<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={cn(
'border-l flex-1 w-0 border-t',
index === replies.length - 1 && 'border-b'
)}
className="flex-1 w-0 pl-10"
event={reply}
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
onClickParent={() => {

View File

@@ -1,53 +1,34 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { useStuff } from '@/hooks/useStuff'
import {
getEventKey,
getReplaceableCoordinateFromEvent,
getRootTag,
isMentioningMutedUsers,
isProtectedEvent,
isReplaceableEvent
} from '@/lib/event'
import { 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'
import SubReplies from './SubReplies'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
const LIMIT = 100
const 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 { 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 = (repliesMap.get(stuffKey)?.events || []).filter((evt) => {
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
@@ -56,11 +37,11 @@ export default function ReplyNoteList({
}
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
const repliesForThisReply = repliesMap.get(replyKey)
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.events.every((evt) => !isUserTrusted(evt.pubkey))
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return false
}
@@ -69,226 +50,69 @@ export default function ReplyNoteList({
replyKeySet.add(key)
return true
})
return replyEvents.sort((a, b) => a.created_at - b.created_at)
return replyEvents.sort((a, b) => b.created_at - a.created_at)
}, [
stuffKey,
repliesMap,
allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
hideUntrustedInteractions,
isUserTrusted
])
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 bottomRef = useRef<HTMLDivElement | null>(null)
// Initial subscription
useEffect(() => {
const fetchRootEvent = async () => {
if (!event && !externalContent) return
let root: TRootInfo = event
? isReplaceableEvent(event.kind)
? {
type: 'A',
id: getReplaceableCoordinateFromEvent(event),
pubkey: event.pubkey,
relay: client.getEventHint(event.id)
}
: { type: 'E', id: event.id, pubkey: event.pubkey }
: { type: 'I', id: externalContent! }
const rootTag = getRootTag(event)
if (rootTag?.type === 'e') {
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
if (rootEventHexId && rootEventPubkey) {
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
} else {
const rootEventId = generateBech32IdFromETag(rootTag.tag)
if (rootEventId) {
const rootEvent = await client.fetchEvent(rootEventId)
if (rootEvent) {
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
}
}
}
} else if (rootTag?.type === 'a') {
const [, coordinate, relay] = rootTag.tag
const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, pubkey, relay }
} else if (rootTag?.type === 'i') {
root = { type: 'I', id: rootTag.tag[1] }
}
setRootInfo(root)
}
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
}
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 loadInitial = async () => {
setInitialLoading(true)
await threadService.subscribe(stuff, LIMIT)
setInitialLoading(false)
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [rootInfo, currentIndex, index])
useEffect(() => {
if (replies.length === 0) {
loadMore()
}
}, [replies])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
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 { 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) => {
const key = getEventKey(reply)
return (
<div key={key}>
<ReplyNote event={reply} />
<SubReplies parentKey={key} />
</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,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

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

@@ -1,7 +1,6 @@
import { kinds } from 'nostr-tools'
import { TMailboxRelay } from './types'
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/',
@@ -63,22 +62,20 @@ 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/']
export const NEW_USER_RELAY_LIST: TMailboxRelay[] = [
{ url: 'wss://nos.lol/', scope: 'both' },
{ url: 'wss://offchain.pub/', scope: 'both' },
{ url: 'wss://relay.damus.io/', scope: 'both' },
{ url: 'wss://nostr.mom/', scope: 'both' }
]
export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = {
@@ -142,7 +139,7 @@ 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 SMESH_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'
export const NIP_96_SERVICE = [

View File

@@ -5,5 +5,6 @@ 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

@@ -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}} حروف',
@@ -489,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': 'فشل إضافة حزمة الرموز التعبيرية',
@@ -590,7 +590,7 @@ export default {
'Publish Highlight': 'نشر التمييز',
'Show replies': 'إظهار الردود',
'Hide replies': 'إخفاء الردود',
'Welcome to Jumble!': 'مرحبًا بك في Jumble!',
'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': 'البحث عن المستخدمين',
@@ -633,6 +633,7 @@ export default {
'أضف كلمة مرور لتشفير مفتاحك الخاص في هذا المتصفح. هذا اختياري لكنه موصى به بشدة لأمان أفضل.',
'Create a password (or skip)': 'أنشئ كلمة مرور (أو تخطى)',
'Enter your password again': 'أدخل كلمة المرور مرة أخرى',
'Complete Signup': 'إكمال التسجيل'
'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',
@@ -503,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',
@@ -607,7 +607,7 @@ export default {
'Publish Highlight': 'Markierung Veröffentlichen',
'Show replies': 'Antworten anzeigen',
'Hide replies': 'Antworten ausblenden',
'Welcome to Jumble!': 'Willkommen bei Jumble!',
'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',
@@ -654,6 +654,7 @@ export default {
'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'
'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',
@@ -250,7 +250,7 @@ export default {
Translation: 'Translation',
Balance: 'Balance',
characters: 'characters',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'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',
@@ -489,14 +489,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',
@@ -593,7 +593,7 @@ export default {
'Publish Highlight': 'Publish Highlight',
'Show replies': 'Show replies',
'Hide replies': 'Hide replies',
'Welcome to Jumble!': 'Welcome to Jumble!',
'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',
@@ -638,6 +638,7 @@ export default {
'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'
'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',
@@ -497,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',
@@ -603,7 +603,7 @@ export default {
'Publish Highlight': 'Publicar Resaltado',
'Show replies': 'Mostrar respuestas',
'Hide replies': 'Ocultar respuestas',
'Welcome to Jumble!': '¡Bienvenido a Jumble!',
'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',
@@ -648,6 +648,7 @@ export default {
'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'
'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}} کاراکتر',
@@ -492,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': 'افزودن بسته ایموجی ناموفق بود',
@@ -596,7 +596,7 @@ export default {
'Publish Highlight': 'انتشار برجسته‌سازی',
'Show replies': 'نمایش پاسخ‌ها',
'Hide replies': 'پنهان کردن پاسخ‌ها',
'Welcome to Jumble!': 'به Jumble خوش آمدید!',
'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': 'جستجوی کاربران',
@@ -643,6 +643,7 @@ export default {
'یک رمز عبور برای رمزگذاری کلید خصوصی خود در این مرورگر اضافه کنید. این اختیاری است اما برای امنیت بهتر به شدت توصیه می‌شود.',
'Create a password (or skip)': 'یک رمز عبور ایجاد کنید (یا رد کنید)',
'Enter your password again': 'رمز عبور خود را دوباره وارد کنید',
'Complete Signup': 'تکمیل ثبت‌نام'
'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',
@@ -502,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",
@@ -605,7 +605,7 @@ export default {
'Publish Highlight': 'Publier le Surlignage',
'Show replies': 'Afficher les réponses',
'Hide replies': 'Masquer les réponses',
'Welcome to Jumble!': 'Bienvenue sur Jumble !',
'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',
@@ -651,6 +651,7 @@ export default {
"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"
'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}} अक्षर',
@@ -494,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': 'इमोजी पैक जोड़ना विफल रहा',
@@ -597,7 +597,7 @@ export default {
'Publish Highlight': 'हाइलाइट प्रकाशित करें',
'Show replies': 'जवाब दिखाएं',
'Hide replies': 'जवाब छुपाएं',
'Welcome to Jumble!': 'Jumble में आपका स्वागत है!',
'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': 'उपयोगकर्ता खोजें',
@@ -644,6 +644,7 @@ export default {
'इस ब्राउज़र में अपनी निजी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड जोड़ें। यह वैकल्पिक है लेकिन बेहतर सुरक्षा के लिए दृढ़ता से अनुशंसित है।',
'Create a password (or skip)': 'एक पासवर्ड बनाएं (या छोड़ें)',
'Enter your password again': 'अपना पासवर्ड फिर से दर्ज करें',
'Complete Signup': 'साइनअप पूर्ण करें'
'Complete Signup': 'साइनअप पूर्ण करें',
Recommended: 'अनुशंसित'
}
}

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'Profil hozzáadása',
'More options': 'További lehetőségek',
'Add client tag': 'Kliens jelölése',
'Show others this was sent via Jumble': 'Mutasd meg másoknak, hogy Jumble-lel küldted',
'Show others this was sent via Smesh': 'Mutasd meg másoknak, hogy Smesh-lel küldted',
'Are you sure you want to logout?': 'Biztosan ki akarsz lépni?',
'relay sets': 'csomópont listák',
edit: 'szerkesztés',
@@ -197,9 +197,9 @@ export default {
All: 'Minden',
Reactions: 'Reakciók',
Zaps: 'Zap-ok',
'Enjoying Jumble?': 'Tetszik a Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Az adományok segítenek a Jumble fenntartásában és továbbfejlesztésében! 😊',
'Enjoying Smesh?': 'Tetszik a Smesh?',
'Your donation helps me maintain Smesh and make it better! 😊':
'Az adományok segítenek a Smesh fenntartásában és továbbfejlesztésében! 😊',
'Earlier notifications': 'Korábbi értesítések',
'Temporarily display this note': 'Poszt ideiglenes megmutatása',
buttonFollowing: 'Követed',
@@ -251,7 +251,7 @@ export default {
Translation: 'Fordítás',
Balance: 'Egyenleg',
characters: 'karakter',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'You can use this API key anywhere else that supports LibreTranslate. The service URL is {{serviceUrl}}',
'Top up': 'Feltöltés',
'Will receive: {n} characters': '{{n}} karakter fogadása',
@@ -491,9 +491,9 @@ export default {
Remote: 'Távoli',
'Encrypted Key': 'Jelszóval védett titkos Kulcs',
'Private Key': 'Titkos Kulcs',
'Welcome to Jumble': 'Isten hozott',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble egy kliens, amivel könnyen böngészhetsz csomópontokat. Kezdd az érdekes csomópontok felderítésével, vagy lépj be, hogy a követettek posztjait megnézd.',
'Welcome to Smesh': 'Isten hozott',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh egy kliens, amivel könnyen böngészhetsz csomópontokat. Kezdd az érdekes csomópontok felderítésével, vagy lépj be, hogy a követettek posztjait megnézd.',
'Explore Relays': 'Csomópontok felderítése',
'Choose a feed': 'Válassz hírfolyamot',
'and {{x}} others': 'és {{x}} másik',
@@ -591,7 +591,7 @@ export default {
'Publish Highlight': 'Kiemelés Közzététele',
'Show replies': 'Válaszok megjelenítése',
'Hide replies': 'Válaszok elrejtése',
'Welcome to Jumble!': 'Üdvözlünk a Jumble-ban!',
'Welcome to Smesh!': 'Üdvözlünk a Smesh-ban!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'A hírcsatornád üres, mert még nem követsz senkit. Kezdd el érdekes tartalmak felfedezésével és kövesd azokat a felhasználókat, akik tetszenek!',
'Search Users': 'Felhasználók keresése',
@@ -636,6 +636,7 @@ export default {
'Adj hozzá jelszót a privát kulcsod titkosításához ebben a böngészőben. Ez opcionális, de erősen ajánlott a jobb biztonság érdekében.',
'Create a password (or skip)': 'Hozz létre jelszót (vagy hagyd ki)',
'Enter your password again': 'Add meg újra a jelszavad',
'Complete Signup': 'Regisztráció befejezése'
'Complete Signup': 'Regisztráció befejezése',
Recommended: 'Ajánlott'
}
}

View File

@@ -95,8 +95,8 @@ export default {
'Add an Account': 'Aggiungi un Account',
'More options': 'Più opzioni',
'Add client tag': 'Aggiungi etichetta del client',
'Show others this was sent via Jumble':
'Mostra agli altri che questo è stato inviato tramite Jumble',
'Show others this was sent via Smesh':
'Mostra agli altri che questo è stato inviato tramite Smesh',
'Are you sure you want to logout?': 'Sei sicuro di volerti scollegare?',
'relay sets': 'set di relay',
edit: 'modifica',
@@ -198,9 +198,9 @@ export default {
All: 'Tutto',
Reactions: 'Reazioni',
Zaps: 'Zaps',
'Enjoying Jumble?': 'Ti sta piacendo Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'La tua donazione mi aiuta a mantenere Jumble e a migliorarlo! 😊',
'Enjoying Smesh?': 'Ti sta piacendo Smesh?',
'Your donation helps me maintain Smesh and make it better! 😊':
'La tua donazione mi aiuta a mantenere Smesh e a migliorarlo! 😊',
'Earlier notifications': 'Notifiche precedenti',
'Temporarily display this note': 'Visualizza temporaneamente questa nota',
buttonFollowing: 'Seguendo',
@@ -254,7 +254,7 @@ export default {
Translation: 'Traduzione',
Balance: 'Saldo',
characters: 'caratteri',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
"Puoi utilizzare questa chiave API ovunque supporti LibreTranslate. L'URL del servizio è {{serviceUrl}}",
'Top up': 'Torna al saldo',
'Will receive: {n} characters': 'Riceverai: {{n}} caratteri',
@@ -497,14 +497,14 @@ export default {
Remote: 'Remoto',
'Encrypted Key': 'Chiave Crittografata',
'Private Key': 'Chiave Privata',
'Welcome to Jumble': 'Benvenuto su Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble è un client focalizzato sulla navigazione dei relay. Inizia esplorando relay interessanti o effettua il login per visualizzare il tuo feed di following.',
'Welcome to Smesh': 'Benvenuto su Smesh',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh è un client focalizzato sulla navigazione dei relay. Inizia esplorando relay interessanti o effettua il login per visualizzare il tuo feed di following.',
'Explore Relays': 'Esplora Relay',
'Choose a feed': 'Scegli un feed',
'and {{x}} others': 'e altri {{x}}',
selfZapWarning:
'Jumble non è responsabile di ciò che accade se zappi te stesso. Procedi a tuo rischio e pericolo. 😉⚡',
'Smesh non è responsabile di ciò che accade se zappi te stesso. Procedi a tuo rischio e pericolo. 😉⚡',
'Emoji Pack': 'Pacchetto Emoji',
'Emoji pack added': 'Pacchetto emoji aggiunto',
'Add emoji pack failed': 'Aggiunta del pacchetto emoji non riuscita',
@@ -602,7 +602,7 @@ export default {
'Publish Highlight': 'Pubblica Evidenziazione',
'Show replies': 'Mostra risposte',
'Hide replies': 'Nascondi risposte',
'Welcome to Jumble!': 'Benvenuto su Jumble!',
'Welcome to Smesh!': 'Benvenuto su Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Il tuo feed è vuoto perché non stai ancora seguendo nessuno. Inizia esplorando contenuti interessanti e seguendo gli utenti che ti piacciono!',
'Search Users': 'Cerca Utenti',
@@ -648,6 +648,7 @@ export default {
'Aggiungi una password per crittografare la tua chiave privata in questo browser. È facoltativo ma fortemente consigliato per una migliore sicurezza.',
'Create a password (or skip)': 'Crea una password (o salta)',
'Enter your password again': 'Inserisci di nuovo la tua password',
'Complete Signup': 'Completa registrazione'
'Complete Signup': 'Completa registrazione',
Recommended: 'Consigliato'
}
}

View File

@@ -96,7 +96,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: 'Zap',
'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: 'フォロー中',
@@ -251,7 +251,7 @@ export default {
Translation: '翻訳',
Balance: '残高',
characters: '文字',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'このAPIキーは、LibreTranslateをサポートする他の場所でも使用できます。サービスURLは{{serviceUrl}}です',
'Top up': 'チャージ',
'Will receive: {n} characters': '受け取る文字数: {{n}} 文字',
@@ -493,14 +493,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 は、あなたが自分自身にザップした場合の結果について責任を負いません。自己責任で続行してください。😉⚡',
'Smesh は、あなたが自分自身にザップした場合の結果について責任を負いません。自己責任で続行してください。😉⚡',
'Emoji Pack': '絵文字パック',
'Emoji pack added': '絵文字パックを追加しました',
'Add emoji pack failed': '絵文字パックの追加に失敗しました',
@@ -597,7 +597,7 @@ export default {
'Publish Highlight': 'ハイライトを公開',
'Show replies': '返信を表示',
'Hide replies': '返信を非表示',
'Welcome to Jumble!': 'Jumbleへようこそ!',
'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': 'ユーザーを検索',
@@ -642,6 +642,7 @@ export default {
'このブラウザで秘密鍵を暗号化するパスワードを追加します。オプションですが、より良いセキュリティのために強くお勧めします。',
'Create a password (or skip)': 'パスワードを作成(またはスキップ)',
'Enter your password again': 'パスワードをもう一度入力',
'Complete Signup': '登録を完了'
'Complete Signup': '登録を完了',
Recommended: 'おすすめ'
}
}

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: '편집',
@@ -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: '팔로잉 중',
@@ -252,7 +252,7 @@ export default {
Translation: '번역',
Balance: '잔액',
characters: '글자',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'이 API 키는 LibreTranslate를 지원하는 모든 곳에서 사용할 수 있습니다. 서비스 주소: {{serviceUrl}}',
'Top up': '충전',
'Will receive: {n} characters': '{{n}} 글자를 받게 됩니다',
@@ -493,14 +493,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': '이모지 팩 추가 실패',
@@ -595,7 +595,7 @@ export default {
'Publish Highlight': '하이라이트 게시',
'Show replies': '답글 표시',
'Hide replies': '답글 숨기기',
'Welcome to Jumble!': 'Jumble에 오신 것을 환영합니다!',
'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': '사용자 검색',
@@ -639,6 +639,7 @@ export default {
'이 브라우저에서 개인 키를 암호화할 비밀번호를 추가합니다. 선택사항이지만 더 나은 보안을 위해 강력히 권장합니다.',
'Create a password (or skip)': '비밀번호 생성(또는 건너뛰기)',
'Enter your password again': '비밀번호를 다시 입력하세요',
'Complete Signup': '가입 완료'
'Complete Signup': '가입 완료',
Recommended: '추천'
}
}

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'Dodaj Konto',
'More options': 'Więcej opcji',
'Add client tag': 'Dodaj tag klienta',
'Show others this was sent via Jumble': 'Pokaż innym, że zostało to wysłane przez Jumble',
'Show others this was sent via Smesh': 'Pokaż innym, że zostało to wysłane przez Smesh',
'Are you sure you want to logout?': 'Czy na pewno chcesz się wylogować?',
'relay sets': 'Zestawy transmiterów',
edit: 'edytuj',
@@ -195,9 +195,9 @@ export default {
All: 'Wszystkie',
Reactions: 'Odzewy',
Zaps: 'Zapy',
'Enjoying Jumble?': 'Podoba ci się Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Twoja darowizna pomoże mi utrzymać i ulepszać Jumble! 😊',
'Enjoying Smesh?': 'Podoba ci się Smesh?',
'Your donation helps me maintain Smesh and make it better! 😊':
'Twoja darowizna pomoże mi utrzymać i ulepszać Smesh! 😊',
'Earlier notifications': 'Wcześniejsze powiadomienia',
// NOTE: The translations below were generated by ChatGPT and have not yet been verified.
@@ -253,7 +253,7 @@ export default {
Translation: 'Tłumaczenie',
Balance: 'Saldo',
characters: 'znaków',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'Ten klucz API możesz używać wszędzie tam, gdzie obsługiwane jest LibreTranslate. Adres usługi to {{serviceUrl}}',
'Top up': 'Doładuj',
'Will receive: {n} characters': 'Otrzymasz: {{n}} znaków',
@@ -497,14 +497,14 @@ export default {
Remote: 'Zdalne',
'Encrypted Key': 'Zaszyfrowany Klucz',
'Private Key': 'Zaszyfrowany Klucz',
'Welcome to Jumble': 'Witamy w Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble to klient skupiający się na przeglądaniu transmiterów. Rozpocznij od odkrycia interesujących transmiterów lub zaloguj się, aby wyświetlić wpisy obserwowanych użytkowników.',
'Welcome to Smesh': 'Witamy w Smesh',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh to klient skupiający się na przeglądaniu transmiterów. Rozpocznij od odkrycia interesujących transmiterów lub zaloguj się, aby wyświetlić wpisy obserwowanych użytkowników.',
'Explore Relays': 'Przeglądaj transmitery',
'Choose a feed': 'Wybierz transmitery',
'and {{x}} others': 'i {{x}} innych',
selfZapWarning:
'Jumble nie ponosi odpowiedzialności za to, co się stanie, jeśli zappujesz samego siebie. Kontynuuj na własne ryzyko. 😉⚡',
'Smesh nie ponosi odpowiedzialności za to, co się stanie, jeśli zappujesz samego siebie. Kontynuuj na własne ryzyko. 😉⚡',
'Emoji Pack': 'Pakiet Emoji',
'Emoji pack added': 'Pakiet emoji dodany',
'Add emoji pack failed': 'Dodawanie pakietu emoji nie powiodło się',
@@ -603,7 +603,7 @@ export default {
'Publish Highlight': 'Opublikuj wyróżnienie',
'Show replies': 'Pokaż odpowiedzi',
'Hide replies': 'Ukryj odpowiedzi',
'Welcome to Jumble!': 'Witamy w Jumble!',
'Welcome to Smesh!': 'Witamy w Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Twój kanał jest pusty, ponieważ jeszcze nikogo nie obserwujesz. Zacznij od odkrywania ciekawych treści i obserwowania użytkowników, którzy Ci się podobają!',
'Search Users': 'Szukaj użytkowników',
@@ -649,6 +649,7 @@ export default {
'Dodaj hasło, aby zaszyfrować swój klucz prywatny w tej przeglądarce. Jest to opcjonalne, ale zdecydowanie zalecane dla lepszego bezpieczeństwa.',
'Create a password (or skip)': 'Utwórz hasło (lub pomiń)',
'Enter your password again': 'Wprowadź hasło ponownie',
'Complete Signup': 'Zakończ rejestrację'
'Complete Signup': 'Zakończ rejestrację',
Recommended: 'Polecane'
}
}

View File

@@ -95,7 +95,7 @@ export default {
'Add an Account': 'Nova conta',
'More options': 'Mais opções',
'Add client tag': 'Adicionar tag de cliente',
'Show others this was sent via Jumble': 'Mostrar aos outros que isso foi enviado via Jumble',
'Show others this was sent via Smesh': 'Mostrar aos outros que isso foi enviado via Smesh',
'Are you sure you want to logout?': 'Tem certeza de que deseja sair?',
'relay sets': 'Conjuntos de relay',
edit: 'Editar',
@@ -198,9 +198,9 @@ export default {
All: 'Tudo',
Reactions: 'Reações',
Zaps: 'Zaps',
'Enjoying Jumble?': 'Gostando do Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Sua doação me ajuda a manter o Jumble e torná-lo melhor! 😊',
'Enjoying Smesh?': 'Gostando do Smesh?',
'Your donation helps me maintain Smesh and make it better! 😊':
'Sua doação me ajuda a manter o Smesh e torná-lo melhor! 😊',
'Earlier notifications': 'Notificações anteriores',
'Temporarily display this note': 'Exibir esta nota temporariamente',
buttonFollowing: 'Seguindo',
@@ -254,7 +254,7 @@ export default {
Translation: 'Tradução',
Balance: 'Saldo',
characters: 'caracteres',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'Esta chave API pode ser usada em qualquer outro lugar que suporte LibreTranslate. O URL do serviço é {{serviceUrl}}',
'Top up': 'Carregar saldo',
'Will receive: {n} characters': 'Receberá: {{n}} caracteres',
@@ -495,14 +495,14 @@ export default {
Remote: 'Remoto',
'Encrypted Key': 'Chave Criptografada',
'Private Key': 'Chave Privada',
'Welcome to Jumble': 'Bem-vindo ao Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble é um cliente focado em navegar relays. Comece explorando relays interessantes ou faça login para ver o conteúdo das pessoas que você segue.',
'Welcome to Smesh': 'Bem-vindo ao Smesh',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh é um cliente focado em navegar relays. Comece explorando relays interessantes ou faça login para ver o conteúdo das pessoas que você segue.',
'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros',
selfZapWarning:
'Jumble não é responsável pelo que acontece se você zapear a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Smesh não é responsável pelo que acontece se você zapear a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Emoji Pack': 'Pacote de Emojis',
'Emoji pack added': 'Pacote de emojis adicionado',
'Add emoji pack failed': 'Falha ao adicionar pacote de emojis',
@@ -599,7 +599,7 @@ export default {
'Publish Highlight': 'Publicar Destaque',
'Show replies': 'Mostrar respostas',
'Hide replies': 'Ocultar respostas',
'Welcome to Jumble!': 'Bem-vindo ao Jumble!',
'Welcome to Smesh!': 'Bem-vindo ao Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'Seu feed está vazio porque você ainda não está seguindo ninguém. Comece explorando conteúdo interessante e seguindo usuários que você gosta!',
'Search Users': 'Buscar Usuários',
@@ -629,12 +629,12 @@ export default {
'Passwords do not match': 'As senhas não coincidem',
'Finish Signup': 'Concluir cadastro',
// New improved signup copy
'Create Your Nostr Account': 'Crie sua conta Nostr',
'Create Your Nostr Account': 'Criando sua conta Nostr',
'Generate your unique private key. This is your digital identity.':
'Gere sua chave privada única. Esta é sua identidade digital.',
'Critical: Save Your Private Key': 'Crítico: Salve sua chave privada',
'Sua chave privada única foi gerada. Ela é sua identidade digital.',
'Critical: Save Your Private Key': 'Importante: Salve a sua chave 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.':
'Sua chave privada É sua conta. Não há recuperação de senha. Se você perdê-la, perderá sua conta para sempre. Por favor, salve-a em um local seguro.',
'Sua chave privada é a sua conta. Não há recuperação de senha, se você perdê-la, perderá sua conta para sempre. Por favor, salve-a em um local seguro.',
'I have safely backed up my private key': 'Fiz backup seguro da minha chave privada',
'Secure Your Account': 'Proteja sua conta',
'Add an extra layer of protection with a password':
@@ -642,8 +642,9 @@ export default {
'Password Protection (Recommended)': 'Proteção por senha (recomendado)',
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
'Adicione uma senha para criptografar sua chave privada neste navegador. Isso é opcional, mas fortemente recomendado para melhor segurança.',
'Create a password (or skip)': 'Crie uma senha (ou pule)',
'Create a password (or skip)': 'Crie uma senha (opcional)',
'Enter your password again': 'Digite sua senha novamente',
'Complete Signup': 'Concluir cadastro'
'Complete Signup': 'Concluir cadastro',
Recommended: 'Recomendado'
}
}

View File

@@ -96,7 +96,7 @@ export default {
'Add an Account': 'Adicionar uma Conta',
'More options': 'Mais opções',
'Add client tag': 'Adicionar tag de cliente',
'Show others this was sent via Jumble': 'Mostrar aos outros que isso foi enviado via Jumble',
'Show others this was sent via Smesh': 'Mostrar aos outros que isso foi enviado via Smesh',
'Are you sure you want to logout?': 'Tem certeza de que deseja sair?',
'relay sets': 'conjuntos de relé',
edit: 'editar',
@@ -198,9 +198,9 @@ export default {
All: 'Tudo',
Reactions: 'Reações',
Zaps: 'Zaps',
'Enjoying Jumble?': 'Gostando do Jumble?',
'Your donation helps me maintain Jumble and make it better! 😊':
'Sua doação me ajuda a manter o Jumble e torná-lo melhor! 😊',
'Enjoying Smesh?': 'Gostando do Smesh?',
'Your donation helps me maintain Smesh and make it better! 😊':
'Sua doação me ajuda a manter o Smesh e torná-lo melhor! 😊',
'Earlier notifications': 'Notificações anteriores',
'Temporarily display this note': 'Exibir esta nota temporariamente',
buttonFollowing: 'Seguindo',
@@ -254,7 +254,7 @@ export default {
Translation: 'Tradução',
Balance: 'Saldo',
characters: 'caracteres',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'Esta chave API pode ser usada em qualquer outro lugar que suporte LibreTranslate. O URL do serviço é {{serviceUrl}}',
'Top up': 'Carregar',
'Will receive: {n} characters': 'Receberá: {{n}} caracteres',
@@ -497,14 +497,14 @@ export default {
Remote: 'Remoto',
'Encrypted Key': 'Chave Criptografada',
'Private Key': 'Chave Privada',
'Welcome to Jumble': 'Bem-vindo ao Jumble',
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble é um cliente focado em explorar relays. Comece por explorar relays interessantes ou inicie sessão para ver o seu feed de seguidos.',
'Welcome to Smesh': 'Bem-vindo ao Smesh',
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Smesh é um cliente focado em explorar relays. Comece por explorar relays interessantes ou inicie sessão para ver o seu feed de seguidos.',
'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros',
selfZapWarning:
'Jumble não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Smesh não é responsável pelo que acontece se você zap a si mesmo. Prossiga por sua conta e risco. 😉⚡',
'Emoji Pack': 'Pacote de Emojis',
'Emoji pack added': 'Pacote de emojis adicionado',
'Add emoji pack failed': 'Falha ao adicionar pacote de emojis',
@@ -601,7 +601,7 @@ export default {
'Publish Highlight': 'Publicar Destaque',
'Show replies': 'Mostrar respostas',
'Hide replies': 'Ocultar respostas',
'Welcome to Jumble!': 'Bem-vindo ao Jumble!',
'Welcome to Smesh!': 'Bem-vindo ao Smesh!',
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!':
'O seu feed está vazio porque ainda não está a seguir ninguém. Comece por explorar conteúdo interessante e siga utilizadores de que gosta!',
'Search Users': 'Procurar Utilizadores',
@@ -647,6 +647,7 @@ export default {
'Adicione uma palavra-passe para encriptar a sua chave privada neste navegador. Isto é opcional, mas fortemente recomendado para melhor segurança.',
'Create a password (or skip)': 'Crie uma palavra-passe (ou ignore)',
'Enter your password again': 'Introduza novamente a sua palavra-passe',
'Complete Signup': 'Concluir registo'
'Complete Signup': 'Concluir registo',
Recommended: 'Recomendado'
}
}

View File

@@ -96,8 +96,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: 'редактировать',
@@ -200,9 +200,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: 'Подписан',
@@ -255,7 +255,7 @@ export default {
Translation: 'Перевод',
Balance: 'Баланс',
characters: 'символов',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'Вы можете использовать этот API-ключ в любом другом месте, которое поддерживает LibreTranslate. URL сервиса: {{serviceUrl}}',
'Top up': 'Пополнить',
'Will receive: {n} characters': 'Получите: {{n}} символов',
@@ -499,14 +499,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 — это клиент, ориентированный на просмотр relay. Начните с изучения интересных relay или войдите, чтобы увидеть ленту подписок.',
'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 — это клиент, ориентированный на просмотр relay. Начните с изучения интересных relay или войдите, чтобы увидеть ленту подписок.',
'Explore Relays': 'Исследовать Relay',
'Choose a feed': 'Выберите ленту',
'and {{x}} others': 'и {{x}} других',
selfZapWarning:
'Jumble не несет ответственности за то, что произойдет, если вы отправите zap самому себе. Продолжайте на свой страх и риск. 😉⚡',
'Smesh не несет ответственности за то, что произойдет, если вы отправите zap самому себе. Продолжайте на свой страх и риск. 😉⚡',
'Emoji Pack': 'Набор эмодзи',
'Emoji pack added': 'Набор эмодзи добавлен',
'Add emoji pack failed': 'Не удалось добавить набор эмодзи',
@@ -602,7 +602,7 @@ export default {
'Publish Highlight': 'Опубликовать Выделение',
'Show replies': 'Показать ответы',
'Hide replies': 'Скрыть ответы',
'Welcome to Jumble!': 'Добро пожаловать в Jumble!',
'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': 'Поиск пользователей',
@@ -648,6 +648,7 @@ export default {
'Добавьте пароль для шифрования вашего приватного ключа в этом браузере. Это необязательно, но настоятельно рекомендуется для лучшей безопасности.',
'Create a password (or skip)': 'Создайте пароль (или пропустите)',
'Enter your password again': 'Введите пароль еще раз',
'Complete Signup': 'Завершить регистрацию'
'Complete Signup': 'Завершить регистрацию',
Recommended: 'Рекомендуемые'
}
}

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: 'ซาตส์',
'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: 'กำลังติดตาม',
@@ -249,7 +249,7 @@ export default {
Translation: 'การแปล',
Balance: 'ยอดคงเหลือ',
characters: 'ตัวอักษร',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'คุณสามารถใช้ API key นี้กับที่อื่นที่รองรับ LibreTranslate ที่อยู่บริการคือ {{serviceUrl}}',
'Top up': 'เติมเงิน',
'Will receive: {n} characters': 'จะได้รับ: {{n}} ตัวอักษร',
@@ -487,14 +487,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,7 +589,7 @@ export default {
'Publish Highlight': 'เผยแพร่ไฮไลท์',
'Show replies': 'แสดงการตอบกลับ',
'Hide replies': 'ซ่อนการตอบกลับ',
'Welcome to Jumble!': 'ยินดีต้อนรับสู่ Jumble!',
'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': 'ค้นหาผู้ใช้',
@@ -633,6 +633,7 @@ export default {
'เพิ่มรหัสผ่านเพื่อเข้ารหัสคีย์ส่วนตัวของคุณในเบราว์เซอร์นี้ เป็นตัวเลือก แต่แนะนำอย่างยิ่งเพื่อความปลอดภัยที่ดีขึ้น',
'Create a password (or skip)': 'สร้างรหัสผ่าน (หรือข้าม)',
'Enter your password again': 'ป้อนรหัสผ่านของคุณอีกครั้ง',
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน'
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน',
Recommended: 'แนะนำ'
}
}

View File

@@ -94,7 +94,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: '已關注',
@@ -251,7 +251,7 @@ export default {
Translation: '翻譯',
Balance: '餘額',
characters: '字元',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'您可以在任何支援 LibreTranslate 的地方使用此 API key。服務位址是 {{serviceUrl}}',
'Top up': '充值',
'Will receive: {n} characters': '將獲得:{{n}} 字元',
@@ -485,13 +485,13 @@ 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 對您給自己打賞所發生的事情概不負責。風險自負。😉⚡',
selfZapWarning: 'Smesh 對您給自己打賞所發生的事情概不負責。風險自負。😉⚡',
'Emoji Pack': '表情包',
'Emoji pack added': '表情包已新增',
'Add emoji pack failed': '新增表情包失敗',
@@ -577,7 +577,7 @@ export default {
'Unfollow Special': '取消特別關注',
'Personal Feeds': '個人訂閱',
'Relay Feeds': '中繼訂閱',
'Welcome to Jumble!': '歡迎來到 Jumble',
'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': '搜尋用戶',
@@ -619,6 +619,7 @@ export default {
'新增密碼以在此瀏覽器中加密你的私鑰。這是可選的,但強烈建議設定以獲得更好的安全性。',
'Create a password (or skip)': '建立密碼(或跳過)',
'Enter your password again': '再次輸入你的密碼',
'Complete Signup': '完成註冊'
'Complete Signup': '完成註冊',
Recommended: '推薦'
}
}

View File

@@ -94,7 +94,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: '已关注',
@@ -251,7 +251,7 @@ export default {
Translation: '翻译',
Balance: '余额',
characters: '字符',
jumbleTranslateApiKeyDescription:
smeshTranslateApiKeyDescription:
'您可以在任何支持 LibreTranslate 的地方使用此 API key。服务地址是 {{serviceUrl}}',
'Top up': '充值',
'Will receive: {n} characters': '将获得: {{n}} 字符',
@@ -485,13 +485,13 @@ 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 对您给自己打赏所发生的事情概不负责。风险自负。😉⚡',
selfZapWarning: 'Smesh 对您给自己打赏所发生的事情概不负责。风险自负。😉⚡',
'Emoji Pack': '表情包',
'Emoji pack added': '表情包已添加',
'Add emoji pack failed': '添加表情包失败',
@@ -582,7 +582,7 @@ export default {
'Publish Highlight': '发布高亮',
'Show replies': '显示回复',
'Hide replies': '隐藏回复',
'Welcome to Jumble!': '欢迎来到 Jumble',
'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': '搜索用户',
@@ -624,6 +624,7 @@ export default {
'添加密码以在此浏览器中加密你的私钥。这是可选的,但强烈建议设置以获得更好的安全性。',
'Create a password (or skip)': '创建密码(或跳过)',
'Enter your password again': '再次输入你的密码',
'Complete Signup': '完成注册'
'Complete Signup': '完成注册',
Recommended: '推荐'
}
}

View File

@@ -103,7 +103,8 @@
animation: shimmer 3s ease-in-out infinite;
}
:root {
/* Light theme variables */
:root, .light {
--surface-background: 0 0% 98%;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
@@ -132,6 +133,39 @@
--chart-5: 27 87% 67%;
--radius: 0.75rem;
}
/* System dark preference - apply dark theme by default */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--surface-background: 240 10% 3.9%;
--background: 0 0% 9%;
--foreground: 0 0% 98%;
--card: 0 0% 12%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 12%;
--popover-foreground: 0 0% 98%;
--primary: 259 43% 56%;
--primary-hover: 259 43% 65%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 18%;
--input: 240 3.7% 15.9%;
--ring: 259 43% 56%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--radius: 0.75rem;
}
}
/* Explicit dark class override */
.dark {
--surface-background: 240 10% 3.9%;
--background: 0 0% 9%;

View File

@@ -16,7 +16,7 @@ import { Event, kinds, nip19 } from 'nostr-tools'
import {
getReplaceableCoordinate,
getReplaceableCoordinateFromEvent,
getRootETag,
getRootTag,
isProtectedEvent,
isReplaceableEvent
} from './event'
@@ -153,7 +153,7 @@ export async function createShortTextNoteDraftEvent(
} = {}
): Promise<TDraftEvent> {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
const { quoteTags, rootETag, parentETag } = await extractRelatedEventIds(
const { quoteTags, rootTag, parentTag } = await extractRelatedEventIds(
transformedEmojisContent,
options.parentEvent
)
@@ -170,13 +170,13 @@ export async function createShortTextNoteDraftEvent(
// q tags
tags.push(...quoteTags)
// e tags
if (rootETag.length) {
tags.push(rootETag)
// thread tags
if (rootTag) {
tags.push(rootTag)
}
if (parentETag.length) {
tags.push(parentETag)
if (parentTag) {
tags.push(parentTag)
}
// p tags
@@ -640,36 +640,41 @@ function generateImetaTags(imageUrls: string[]) {
}
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
let rootETag: string[] = []
let parentETag: string[] = []
let rootTag: string[] | null = null
let parentTag: string[] | null = null
const quoteTags = extractQuoteTags(content)
if (parentEvent) {
const _rootETag = getRootETag(parentEvent)
if (_rootETag) {
parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const _rootTag = getRootTag(parentEvent)
if (_rootTag?.type === 'e') {
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag
const [, rootEventHexId, hint, , rootEventPubkey] = _rootTag.tag
if (rootEventPubkey) {
rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
rootTag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
} else {
const rootEventId = generateBech32IdFromETag(_rootETag)
const rootEventId = generateBech32IdFromETag(_rootTag.tag)
const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
rootETag = rootEvent
rootTag = rootEvent
? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
: buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
}
} else if (_rootTag?.type === 'a') {
// Legacy
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
const [, coordinate, hint] = _rootTag.tag
rootTag = buildLegacyRootATag(coordinate, hint)
} else {
// reply to root event
rootETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
rootTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
}
}
return {
quoteTags,
rootETag,
parentETag
rootTag,
parentTag
}
}
@@ -823,6 +828,16 @@ function buildETagWithMarker(
return trimTagEnd(['e', eventHexId, hint, marker, pubkey])
}
function buildLegacyRootATag(coordinate: string, hint: string = '') {
if (!hint) {
const evt = client.getReplaeableEventFromCache(coordinate)
if (evt) {
hint = client.getEventHint(evt.id)
}
}
return trimTagEnd(['a', coordinate, hint, 'root'])
}
function buildITag(url: string, upperCase: boolean = false) {
return [upperCase ? 'I' : 'i', url]
}
@@ -877,7 +892,7 @@ function buildResponseTag(value: string) {
}
function buildClientTag() {
return ['client', 'jumble']
return ['client', 'smesh']
}
function buildNsfwTag() {

View File

@@ -83,6 +83,14 @@ export function getParentETag(event?: Event) {
return tag
}
function getLegacyParentATag(event?: Event) {
if (!event || event.kind !== kinds.ShortTextNote) {
return undefined
}
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'reply')
}
export function getParentATag(event?: Event) {
if (
!event ||
@@ -114,8 +122,9 @@ export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: strin
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
const tag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined
const tag = getLegacyParentATag(event) ?? getParentETag(event) ?? getLegacyRootATag(event)
if (!tag) return undefined
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
}
// NIP-22
@@ -164,6 +173,14 @@ export function getRootETag(event?: Event) {
return tag
}
function getLegacyRootATag(event?: Event) {
if (!event || event.kind !== kinds.ShortTextNote) {
return undefined
}
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'root')
}
export function getRootATag(event?: Event) {
if (
!event ||
@@ -195,8 +212,9 @@ export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[
if (!event) return undefined
if (event.kind === kinds.ShortTextNote) {
const tag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined
const tag = getLegacyRootATag(event) ?? getRootETag(event)
if (!tag) return undefined
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
}
// NIP-22

View File

@@ -16,3 +16,27 @@ export function checkNip43Support(relayInfo: TRelayInfo | undefined) {
export function filterOutBigRelays(relayUrls: string[]) {
return relayUrls.filter((url) => !BIG_RELAY_URLS.includes(url))
}
export function recommendRelaysByLanguage(i18nLanguage: string) {
if (i18nLanguage.startsWith('zh')) {
return [
'wss://relay.nostrzh.org/',
'wss://relay.nostr.moe/',
'wss://lang.relays.land/zh',
'wss://relay.stream/'
]
}
if (i18nLanguage.startsWith('ja')) {
return ['wss://yabu.me/', 'wss://lang.relays.land/ja']
}
if (i18nLanguage.startsWith('es')) {
return ['wss://lang.relays.land/es']
}
if (i18nLanguage.startsWith('it')) {
return ['wss://lang.relays.land/it']
}
if (i18nLanguage.startsWith('pt')) {
return ['wss://lang.relays.land/pt']
}
return []
}

View File

@@ -46,7 +46,7 @@ export default function FollowingFeed() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-6 text-center">
<UserPlus size={64} className="text-muted-foreground mb-4" strokeWidth={1.5} />
<h2 className="text-2xl font-semibold mb-2">{t('Welcome to Jumble!')}</h2>
<h2 className="text-2xl font-semibold mb-2">{t('Welcome to Smesh!')}</h2>
<p className="text-muted-foreground mb-6 max-w-md">
{t(
'Your feed is empty because you are not following anyone yet. Start by exploring interesting content and following users you like!'

View File

@@ -186,12 +186,12 @@ function WelcomeGuide() {
<div className="space-y-2">
<div className="flex items-center w-full justify-center gap-2">
<Sparkles className="text-yellow-400" />
<h2 className="text-2xl font-bold">{t('Welcome to Jumble')}</h2>
<h2 className="text-2xl font-bold">{t('Welcome to Smesh')}</h2>
<Sparkles className="text-yellow-400" />
</div>
<p className="text-muted-foreground max-w-md">
{t(
'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.'
'Smesh is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.'
)}
</p>
</div>

View File

@@ -33,7 +33,7 @@ const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<ExternalContentInteractions pageIndex={index} externalContent={id} />
<ExternalContentInteractions externalContent={id} />
</SecondaryPageLayout>
)
})

View File

@@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
<NoteInteractions key={`note-interactions-${event.id}`} event={event} />
</SecondaryPageLayout>
)
})

View File

@@ -1,10 +0,0 @@
import { AccountInfo } from './AccountInfo'
import { JumbleTranslateAccountProvider } from './JumbleTranslateAccountProvider'
export default function JumbleTranslate() {
return (
<JumbleTranslateAccountProvider>
<AccountInfo />
</JumbleTranslateAccountProvider>
)
}

View File

@@ -1,18 +1,18 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { JUMBLE_API_BASE_URL } from '@/constants'
import { SMESH_API_BASE_URL } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Eye, EyeOff } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
import { useSmeshTranslateAccount } from './SmeshTranslateAccountProvider'
import RegenerateApiKeyButton from './RegenerateApiKeyButton'
import TopUp from './TopUp'
export function AccountInfo() {
const { t } = useTranslation()
const { pubkey, startLogin } = useNostr()
const { account } = useJumbleTranslateAccount()
const { account } = useSmeshTranslateAccount()
const [showApiKey, setShowApiKey] = useState(false)
const [copied, setCopied] = useState(false)
@@ -63,8 +63,8 @@ export function AccountInfo() {
<RegenerateApiKeyButton />
</div>
<p className="text-sm text-muted-foreground select-text">
{t('jumbleTranslateApiKeyDescription', {
serviceUrl: new URL('/v1/translation', JUMBLE_API_BASE_URL).toString()
{t('smeshTranslateApiKeyDescription', {
serviceUrl: new URL('/v1/translation', SMESH_API_BASE_URL).toString()
})}
</p>
</div>

View File

@@ -11,11 +11,11 @@ import {
import { Loader, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
import { useSmeshTranslateAccount } from './SmeshTranslateAccountProvider'
export default function RegenerateApiKeyButton() {
const { t } = useTranslation()
const { account, regenerateApiKey } = useJumbleTranslateAccount()
const { account, regenerateApiKey } = useSmeshTranslateAccount()
const [resettingApiKey, setResettingApiKey] = useState(false)
const [showResetDialog, setShowResetDialog] = useState(false)

View File

@@ -4,27 +4,27 @@ import { TTranslationAccount } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react'
import { toast } from 'sonner'
type TJumbleTranslateAccountContext = {
type TSmeshTranslateAccountContext = {
account: TTranslationAccount | null
getAccount: () => Promise<void>
regenerateApiKey: () => Promise<void>
}
export const JumbleTranslateAccountContext = createContext<
TJumbleTranslateAccountContext | undefined
export const SmeshTranslateAccountContext = createContext<
TSmeshTranslateAccountContext | undefined
>(undefined)
export const useJumbleTranslateAccount = () => {
const context = useContext(JumbleTranslateAccountContext)
export const useSmeshTranslateAccount = () => {
const context = useContext(SmeshTranslateAccountContext)
if (!context) {
throw new Error(
'useJumbleTranslateAccount must be used within a JumbleTranslateAccountProvider'
'useSmeshTranslateAccount must be used within a SmeshTranslateAccountProvider'
)
}
return context
}
export function JumbleTranslateAccountProvider({ children }: { children: React.ReactNode }) {
export function SmeshTranslateAccountProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr()
const { getAccount: _getAccount, regenerateApiKey: _regenerateApiKey } = useTranslationService()
const [account, setAccount] = useState<TTranslationAccount | null>(null)
@@ -55,7 +55,7 @@ export function JumbleTranslateAccountProvider({ children }: { children: React.R
}
} catch (error) {
toast.error(
'Failed to regenerate Jumble translation API key: ' +
'Failed to regenerate Smesh translation API key: ' +
(error instanceof Error
? error.message
: 'An error occurred while regenerating the API key')
@@ -72,7 +72,7 @@ export function JumbleTranslateAccountProvider({ children }: { children: React.R
}
} catch (error) {
toast.error(
'Failed to fetch Jumble translation account: ' +
'Failed to fetch Smesh translation account: ' +
(error instanceof Error ? error.message : 'An error occurred while fetching the account')
)
setAccount(null)
@@ -80,8 +80,8 @@ export function JumbleTranslateAccountProvider({ children }: { children: React.R
}
return (
<JumbleTranslateAccountContext.Provider value={{ account, getAccount, regenerateApiKey }}>
<SmeshTranslateAccountContext.Provider value={{ account, getAccount, regenerateApiKey }}>
{children}
</JumbleTranslateAccountContext.Provider>
</SmeshTranslateAccountContext.Provider>
)
}

View File

@@ -7,13 +7,13 @@ import { closeModal, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { useJumbleTranslateAccount } from './JumbleTranslateAccountProvider'
import { useSmeshTranslateAccount } from './SmeshTranslateAccountProvider'
import { useTranslation } from 'react-i18next'
export default function TopUp() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { getAccount } = useJumbleTranslateAccount()
const { getAccount } = useSmeshTranslateAccount()
const [topUpLoading, setTopUpLoading] = useState(false)
const [topUpAmount, setTopUpAmount] = useState(1000)
const [selectedAmount, setSelectedAmount] = useState<number | null>(1000)

View File

@@ -0,0 +1,10 @@
import { AccountInfo } from './AccountInfo'
import { SmeshTranslateAccountProvider } from './SmeshTranslateAccountProvider'
export default function SmeshTranslate() {
return (
<SmeshTranslateAccountProvider>
<AccountInfo />
</SmeshTranslateAccountProvider>
)
}

View File

@@ -12,7 +12,7 @@ import { useTranslationService } from '@/providers/TranslationServiceProvider'
import { TLanguage } from '@/types'
import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import JumbleTranslate from './JumbleTranslate'
import SmeshTranslate from './SmeshTranslate'
import LibreTranslate from './LibreTranslate'
const TranslationPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -53,19 +53,19 @@ const TranslationPage = forwardRef(({ index }: { index?: number }, ref) => {
defaultValue={config.service}
value={config.service}
onValueChange={(newService) => {
updateConfig({ service: newService as 'jumble' | 'libre_translate' })
updateConfig({ service: newService as 'smesh' | 'libre_translate' })
}}
>
<SelectTrigger id="translation-service-select" className="w-[180px]">
<SelectValue placeholder={t('Select Translation Service')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="jumble">Jumble</SelectItem>
<SelectItem value="smesh">Smesh</SelectItem>
<SelectItem value="libre_translate">LibreTranslate</SelectItem>
</SelectContent>
</Select>
</div>
{config.service === 'jumble' ? <JumbleTranslate /> : <LibreTranslate />}
{config.service === 'smesh' ? <SmeshTranslate /> : <LibreTranslate />}
</div>
</SecondaryPageLayout>
)

View File

@@ -1,5 +1,5 @@
import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, NEW_USER_RELAY_LIST } from '@/constants'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
@@ -614,13 +614,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const setupNewUser = async (signer: ISigner) => {
const relays = NEW_USER_RELAY_LIST.map((item) => item.url)
await Promise.allSettled([
client.publishEvent(relays, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(relays, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
client.publishEvent(
relays.concat(BIG_RELAY_URLS),
await signer.signEvent(createRelayListDraftEvent(NEW_USER_RELAY_LIST))
BIG_RELAY_URLS,
await signer.signEvent(
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
)
])
}

View File

@@ -192,9 +192,9 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
// Update title
if (newNotificationCount > 0) {
document.title = `(${newNotificationCount >= 10 ? '9+' : newNotificationCount}) Jumble`
document.title = `(${newNotificationCount >= 10 ? '9+' : newNotificationCount}) Smesh`
} else {
document.title = 'Jumble'
document.title = 'Smesh'
}
// Update favicons

View File

@@ -1,71 +0,0 @@
import { getEventKey, getKeyFromTag, getParentTag, isReplyNoteEvent } from '@/lib/event'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = {
repliesMap: Map<string, { events: Event[]; eventKeySet: Set<string> }>
addReplies: (replies: Event[]) => void
}
const ReplyContext = createContext<TReplyContext | undefined>(undefined)
export const useReply = () => {
const context = useContext(ReplyContext)
if (!context) {
throw new Error('useReply must be used within a ReplyProvider')
}
return context
}
export function ReplyProvider({ children }: { children: React.ReactNode }) {
const [repliesMap, setRepliesMap] = useState<
Map<string, { events: Event[]; eventKeySet: Set<string> }>
>(new Map())
const addReplies = useCallback((replies: Event[]) => {
const newReplyKeySet = new Set<string>()
const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => {
if (!isReplyNoteEvent(reply)) return
const key = getEventKey(reply)
if (newReplyKeySet.has(key)) return
newReplyKeySet.add(key)
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) {
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
}
}
})
if (newReplyEventMap.size === 0) return
setRepliesMap((prev) => {
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const replies = prev.get(key) || { events: [], eventKeySet: new Set() }
newReplyEvents.forEach((reply) => {
const key = getEventKey(reply)
if (!replies.eventKeySet.has(key)) {
replies.events.push(reply)
replies.eventKeySet.add(key)
}
})
prev.set(key, replies)
}
return new Map(prev)
})
}, [])
return (
<ReplyContext.Provider
value={{
repliesMap,
addReplies
}}
>
{children}
</ReplyContext.Provider>
)
}

View File

@@ -36,7 +36,7 @@ export const useTranslationService = () => {
export function TranslationServiceProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation()
const [config, setConfig] = useState<TTranslationServiceConfig>({ service: 'jumble' })
const [config, setConfig] = useState<TTranslationServiceConfig>({ service: 'smesh' })
const { pubkey, startLogin } = useNostr()
const [translatedEventIdSet, setTranslatedEventIdSet] = useState<Set<string>>(new Set())
@@ -47,7 +47,7 @@ export function TranslationServiceProvider({ children }: { children: React.React
}, [pubkey])
const getAccount = async (): Promise<TTranslationAccount | void> => {
if (config.service !== 'jumble') return
if (config.service !== 'smesh') return
if (!pubkey) {
startLogin()
return
@@ -56,7 +56,7 @@ export function TranslationServiceProvider({ children }: { children: React.React
}
const regenerateApiKey = async (): Promise<string | undefined> => {
if (config.service !== 'jumble') return
if (config.service !== 'smesh') return
if (!pubkey) {
startLogin()
return
@@ -71,7 +71,7 @@ export function TranslationServiceProvider({ children }: { children: React.React
}
const translate = async (text: string, target: string): Promise<string> => {
if (config.service === 'jumble') {
if (config.service === 'smesh') {
return await translation.translate(text, target)
} else {
return await libreTranslate.translate(text, target, config.server, config.api_key)
@@ -146,7 +146,7 @@ export function TranslationServiceProvider({ children }: { children: React.React
}
const translateEvent = async (event: Event): Promise<Event | void> => {
if (config.service === 'jumble' && !pubkey) {
if (config.service === 'smesh' && !pubkey) {
startLogin()
return
}

View File

@@ -842,6 +842,10 @@ class ClientService extends EventTarget {
}
}
getReplaeableEventFromCache(coordinate: string): NEvent | undefined {
return this.replaceableEventCacheMap.get(coordinate)
}
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
if (event) {

View File

@@ -3,28 +3,27 @@ import DataLoader from 'dataloader'
class FayanService {
static instance: FayanService
private userPercentileDataLoader = new DataLoader<string, number | null>(async (userIds) => {
return await Promise.all(
userIds.map(async (userId) => {
try {
const res = await fetch(`https://fayan.jumble.social/${userId}`)
if (!res.ok) {
if (res.status === 404) {
return 0
}
return null
}
const data = await res.json()
if (typeof data.percentile === 'number') {
return data.percentile
}
return null
} catch {
return null
private userPercentileDataLoader = new DataLoader<string, number | null>(
async (pubkeys) => {
try {
const res = await fetch(`https://fayan.smesh.social/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pubkeys })
})
if (!res.ok) {
return new Array(pubkeys.length).fill(null)
}
})
)
})
const data = await res.json()
return pubkeys.map((pubkey) => data[pubkey] ?? 0)
} catch {
return new Array(pubkeys.length).fill(null)
}
},
{ maxBatchSize: 50 }
)
constructor() {
if (!FayanService.instance) {

View File

@@ -45,7 +45,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 10)
const request = window.indexedDB.open('smesh', 10)
request.onerror = (event) => {
reject(event)

View File

@@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, CODY_PUBKEY, JUMBLE_PUBKEY } from '@/constants'
import { BIG_RELAY_URLS, CODY_PUBKEY, SMESH_PUBKEY } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { TProfile } from '@/types'
import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
@@ -14,7 +14,7 @@ import client from './client.service'
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
const OFFICIAL_PUBKEYS = [JUMBLE_PUBKEY, CODY_PUBKEY]
const OFFICIAL_PUBKEYS = [SMESH_PUBKEY, CODY_PUBKEY]
class LightningService {
static instance: LightningService
@@ -25,7 +25,7 @@ class LightningService {
if (!LightningService.instance) {
LightningService.instance = this
init({
appName: 'Jumble',
appName: 'Smesh',
showBalance: false
})
}

View File

@@ -442,7 +442,7 @@ class LocalStorageService {
}
getTranslationServiceConfig(pubkey?: string | null) {
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' }
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'smesh' }
}
setTranslationServiceConfig(config: TTranslationServiceConfig, pubkey?: string | null) {

View File

@@ -0,0 +1,378 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
getEventKey,
getKeyFromTag,
getParentTag,
getReplaceableCoordinateFromEvent,
getRootTag,
isProtectedEvent,
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { generateBech32IdFromETag } from '@/lib/tag'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Filter, kinds, NostrEvent } from 'nostr-tools'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
class ThreadService {
static instance: ThreadService
private rootInfoCache = new Map<string, Promise<TRootInfo | undefined>>()
private subscriptions = new Map<
string,
{
promise: Promise<{
closer: () => void
timelineKey: string
}>
count: number
until?: number
}
>()
private threadMap = new Map<string, NostrEvent[]>()
private processedReplyKeys = new Set<string>()
private parentKeyMap = new Map<string, string>()
private descendantCache = new Map<string, Map<string, NostrEvent[]>>()
private threadListeners = new Map<string, Set<() => void>>()
private allDescendantThreadsListeners = new Map<string, Set<() => void>>()
private readonly EMPTY_ARRAY: NostrEvent[] = []
private readonly EMPTY_MAP: Map<string, NostrEvent[]> = new Map()
constructor() {
if (!ThreadService.instance) {
ThreadService.instance = this
}
return ThreadService.instance
}
async subscribe(stuff: NostrEvent | string, limit = 100) {
const { event } = this.resolveStuff(stuff)
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) {
subscription.count += 1
return
}
const _subscribe = async () => {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
}
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
// If current event is protected, we can assume its replies are also protected and stored on the same relays
if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
} else if (rootInfo.type === 'A') {
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
}
)
if (rootInfo.relay) {
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
let resolve: () => void
const _promise = new Promise<void>((res) => {
resolve = res
})
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{
onEvents: (events, eosed) => {
if (events.length > 0) {
this.addRepliesToThread(events)
}
if (eosed) {
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription && events.length > 0) {
subscription.until = events[events.length - 1].created_at - 1
}
resolve()
}
},
onNew: (evt) => {
this.addRepliesToThread([evt])
}
}
)
await _promise
return { closer, timelineKey }
}
const promise = _subscribe()
this.subscriptions.set(rootInfo.id, {
promise,
count: 1,
until: dayjs().unix()
})
await promise
}
async unsubscribe(stuff: NostrEvent | string) {
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return
const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return
setTimeout(() => {
subscription.count -= 1
if (subscription.count <= 0) {
this.subscriptions.delete(rootInfo.id)
subscription.promise.then(({ closer }) => {
closer()
})
}
}, 2000)
}
async loadMore(stuff: NostrEvent | string, limit = 100): Promise<boolean> {
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return false
const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return false
const { timelineKey } = await subscription.promise
if (!timelineKey) return false
if (!subscription.until) return false
const events = await client.loadMoreTimeline(timelineKey, subscription.until, limit)
this.addRepliesToThread(events)
const { event } = this.resolveStuff(stuff)
let newUntil = events.length ? events[events.length - 1].created_at - 1 : undefined
if (newUntil && event && !isReplaceableEvent(event.kind) && newUntil < event.created_at) {
newUntil = undefined
}
subscription.until = newUntil
return !!newUntil
}
addRepliesToThread(replies: NostrEvent[]) {
const newReplyEventMap = new Map<string, NostrEvent[]>()
replies.forEach((reply) => {
const key = getEventKey(reply)
if (this.processedReplyKeys.has(key)) return
this.processedReplyKeys.add(key)
if (!isReplyNoteEvent(reply)) return
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) {
const thread = newReplyEventMap.get(parentKey) ?? []
thread.push(reply)
newReplyEventMap.set(parentKey, thread)
this.parentKeyMap.set(key, parentKey)
}
}
})
if (newReplyEventMap.size === 0) return
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const thread = this.threadMap.get(key) ?? []
thread.push(...newReplyEvents)
this.threadMap.set(key, thread)
}
this.descendantCache.clear()
for (const key of newReplyEventMap.keys()) {
this.notifyThreadUpdate(key)
this.notifyAllDescendantThreadsUpdate(key)
}
}
getThread(stuffKey: string): NostrEvent[] {
return this.threadMap.get(stuffKey) ?? this.EMPTY_ARRAY
}
getAllDescendantThreads(stuffKey: string): Map<string, NostrEvent[]> {
const cached = this.descendantCache.get(stuffKey)
if (cached) return cached
const build = () => {
const thread = this.threadMap.get(stuffKey)
if (!thread || thread.length === 0) {
return this.EMPTY_MAP
}
const result = new Map<string, NostrEvent[]>()
const keys: string[] = [stuffKey]
while (keys.length > 0) {
const key = keys.pop()!
const thread = this.threadMap.get(key) ?? []
if (thread.length > 0) {
result.set(key, thread)
thread.forEach((reply) => {
const replyKey = getEventKey(reply)
keys.push(replyKey)
})
}
}
return result
}
const allThreads = build()
this.descendantCache.set(stuffKey, allThreads)
return allThreads
}
listenThread(key: string, callback: () => void) {
let set = this.threadListeners.get(key)
if (!set) {
set = new Set()
this.threadListeners.set(key, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.threadListeners.delete(key)
}
}
private notifyThreadUpdate(key: string) {
const set = this.threadListeners.get(key)
if (set) {
set.forEach((cb) => cb())
}
}
listenAllDescendantThreads(key: string, callback: () => void) {
let set = this.allDescendantThreadsListeners.get(key)
if (!set) {
set = new Set()
this.allDescendantThreadsListeners.set(key, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.allDescendantThreadsListeners.delete(key)
}
}
private notifyAllDescendantThreadsUpdate(key: string) {
const notify = (_key: string) => {
const set = this.allDescendantThreadsListeners.get(_key)
if (set) {
set.forEach((cb) => cb())
}
}
notify(key)
let parentKey = this.parentKeyMap.get(key)
while (parentKey) {
notify(parentKey)
parentKey = this.parentKeyMap.get(parentKey)
}
}
private async parseRootInfo(stuff: NostrEvent | string): Promise<TRootInfo | undefined> {
const { event, externalContent } = this.resolveStuff(stuff)
if (!event && !externalContent) return
const cacheKey = event ? getEventKey(event) : externalContent!
const cache = this.rootInfoCache.get(cacheKey)
if (cache) return cache
const _parseRootInfo = async (): Promise<TRootInfo | undefined> => {
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] }
}
return root
}
const promise = _parseRootInfo()
this.rootInfoCache.set(cacheKey, promise)
return promise
}
private resolveStuff(stuff: NostrEvent | string) {
return typeof stuff === 'string'
? { event: undefined, externalContent: stuff, stuffKey: stuff }
: { event: stuff, externalContent: undefined, stuffKey: getEventKey(stuff) }
}
}
const instance = new ThreadService()
export default instance

View File

@@ -1,4 +1,4 @@
import { JUMBLE_API_BASE_URL } from '@/constants'
import { SMESH_API_BASE_URL } from '@/constants'
class TransactionService {
static instance: TransactionService
@@ -17,7 +17,7 @@ class TransactionService {
transactionId: string
invoiceId: string
}> {
const url = new URL('/v1/transactions', JUMBLE_API_BASE_URL).toString()
const url = new URL('/v1/transactions', SMESH_API_BASE_URL).toString()
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -39,7 +39,7 @@ class TransactionService {
async checkTransaction(transactionId: string): Promise<{
state: 'pending' | 'failed' | 'settled'
}> {
const url = new URL(`/v1/transactions/${transactionId}/check`, JUMBLE_API_BASE_URL).toString()
const url = new URL(`/v1/transactions/${transactionId}/check`, SMESH_API_BASE_URL).toString()
const response = await fetch(url, {
method: 'POST'
})

View File

@@ -1,4 +1,4 @@
import { JUMBLE_API_BASE_URL } from '@/constants'
import { SMESH_API_BASE_URL } from '@/constants'
import client from '@/services/client.service'
import { TTranslationAccount } from '@/types'
@@ -25,9 +25,9 @@ class TranslationService {
let auth: string | undefined
if (!apiKey) {
auth = await client.signHttpAuth(
new URL(path, JUMBLE_API_BASE_URL).toString(),
new URL(path, SMESH_API_BASE_URL).toString(),
method,
'Auth to get Jumble translation service account'
'Auth to get Smesh translation service account'
)
}
const act = await this._fetch<TTranslationAccount>({
@@ -113,7 +113,7 @@ class TranslationService {
_auth = `Bearer ${act.api_key}`
}
const url = new URL(path, JUMBLE_API_BASE_URL).toString()
const url = new URL(path, SMESH_API_BASE_URL).toString()
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', Authorization: _auth },

13
src/types/index.d.ts vendored
View File

@@ -1,5 +1,10 @@
import { Event, Filter, VerifiedEvent } from 'nostr-tools'
import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, NSFW_DISPLAY_POLICY, POLL_TYPE } from '../constants'
import {
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
NSFW_DISPLAY_POLICY,
POLL_TYPE
} from '../constants'
export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: number }
@@ -145,7 +150,7 @@ export type TTranslationAccount = {
export type TTranslationServiceConfig =
| {
service: 'jumble'
service: 'smesh'
}
| {
service: 'libre_translate'
@@ -200,12 +205,10 @@ export type TNotificationStyle =
export type TAwesomeRelayCollection = {
id: string
name: string
description: string
relays: string[]
}
export type TMediaAutoLoadPolicy =
(typeof MEDIA_AUTO_LOAD_POLICY)[keyof typeof MEDIA_AUTO_LOAD_POLICY]
export type TNsfwDisplayPolicy =
(typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]
export type TNsfwDisplayPolicy = (typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]

View File

@@ -25,6 +25,9 @@ const getAppVersion = () => {
// https://vite.dev/config/
export default defineConfig({
server: {
allowedHosts: ['smesh.mleku.dev']
},
define: {
'import.meta.env.GIT_COMMIT': getGitHash(),
'import.meta.env.APP_VERSION': getAppVersion()
@@ -48,8 +51,8 @@ export default defineConfig({
enabled: true
},
manifest: {
name: 'Jumble',
short_name: 'Jumble',
name: 'Smesh',
short_name: 'Smesh',
icons: [
{
src: '/pwa-512x512.png',
@@ -84,8 +87,8 @@ export default defineConfig({
],
start_url: '/',
display: 'standalone',
background_color: '#FFFFFF',
theme_color: '#FFFFFF',
background_color: '#171717',
theme_color: '#171717',
description: packageJson.description
}
})