diff --git a/.gitignore b/.gitignore
index 9caed82..92ab93c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,8 +58,8 @@ yarn-error.*
# local env files
.env
-/.env
-.env*.local
+.env.local
+.env.production
# typescript
*.tsbuildinfo
diff --git a/AGENTS.md b/AGENTS.md
index d00a3b5..b3ec30b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,13 +1,11 @@
-# SkateHive Mobile App — Agent Guide
+# SkateHive Mobile App – Agent Guide
-This file provides context for AI agents working on this codebase.
+This file provides critical context for AI agents working on the SkateHive mobile codebase.
## Repository Overview
-
-**SkateHive** is a React Native/Expo mobile app for a skateboarding community built on the HIVE blockchain. Users post skateboarding content (photos, videos, text), vote on posts, comment, follow each other, and earn crypto rewards (HIVE/HBD).
+**SkateHive** is a React Native/Expo mobile app for the skateboarding community built on the HIVE blockchain. Users post content, vote, comment, and earn crypto rewards (HIVE/HBD).
## Architecture Summary
-
```
┌─────────────────────────────────────────────────┐
│ Expo Router │
@@ -17,7 +15,7 @@ This file provides context for AI agents working on this codebase.
│ components/Feed/ components/auth/ components/ui/ │
├─────────────────────────────────────────────────┤
│ Business Logic (lib/) │
-│ auth-provider hive-utils secure-key upload/ │
+│ auth-provider hive-utils secure-key theme │
├─────────────────────────────────────────────────┤
│ Data Layer │
│ React Query | HIVE RPC Nodes | REST API │
@@ -27,122 +25,39 @@ This file provides context for AI agents working on this codebase.
└─────────────────────────────────────────────────┘
```
-## Critical Files to Understand First
-
-| File | Purpose | Lines |
-|------|---------|-------|
-| `lib/auth-provider.tsx` | Authentication context, session mgmt, multi-account, biometric/PIN | ~490 |
-| `lib/hive-utils.ts` | ALL blockchain operations (vote, comment, follow, power-up, etc.) | ~1200 |
-| `lib/secure-key.ts` | AES encryption of private keys with PBKDF2 key derivation | ~150 |
-| `lib/types.ts` | TypeScript interfaces for Post, AuthSession, etc. | ~130 |
-| `lib/theme.ts` | Complete design system (colors, spacing, fonts, radii) | ~65 |
-| `lib/constants.ts` | API URLs, community tag, app name | ~25 |
-| `app/_layout.tsx` | Root layout wrapping all context providers | — |
-| `app/(tabs)/_layout.tsx` | Tab bar configuration (5 visible + 1 hidden tab) | — |
+## Critical Files
+- `lib/auth-provider.tsx`: Auth context, session, multi-account, biometric/PIN logic.
+- `lib/hive-utils.ts`: ALL blockchain operations (vote, comment, follow, etc.).
+- `lib/secure-key.ts`: AES encryption of private keys (PBKDF2).
+- `lib/theme.ts`: Central design system (colors, spacing, fonts).
+- `app/_layout.tsx`: Root layout wrapping all providers.
## Agent Task Patterns
### Adding a New Screen
-1. Create file in `app/` (or `app/(tabs)/` for tabbed screens)
-2. Expo Router auto-registers routes from file names
-3. Protected routes check `useAuth()` session in `app/_layout.tsx`
-4. Import theme from `lib/theme.ts` for consistent styling
-
-### Adding a New Blockchain Operation
-1. Add function in `lib/hive-utils.ts`
-2. Use `hiveClient` (pre-configured with failover nodes)
-3. Require `decryptedKey` from `useAuth()` session
-4. Wrap in try/catch — blockchain ops can fail on any node
+1. Create file in `app/` (or `app/(tabs)/` for tabbed screens).
+2. Expo Router auto-registers routes from filenames.
+3. Import `theme` from `lib/theme.ts` for all styling.
-### Adding a New Hook
-1. Create in `lib/hooks/`
-2. Use `useQuery`/`useMutation` from `@tanstack/react-query`
-3. Follow existing patterns in `useQueries.ts` for cache keys and stale times
-4. Export and use in components
+### Blockchain Operations
+1. Use functions in `lib/hive-utils.ts`.
+2. Wrap in try/catch — RPC nodes can fail; the client handles failover.
+3. Require `decryptedKey` from `useAuth()` session.
-### Modifying the Feed
-- Feed data flows: `useSnaps()` -> `getSnapsContainers()` -> `getContentReplies()`
-- Each post is rendered by `components/Feed/PostCard.tsx`
-- Media parsing happens inside PostCard (extracts images/videos from markdown body)
-- Voting UI uses `components/ui/VotingSlider.tsx`
-
-### Media Upload Changes
-- Images: `lib/upload/image-upload.ts` (HEIC conversion + HIVE image hosting)
-- Videos: `lib/upload/video-upload.ts` (dynamic transcoder discovery + IPFS)
-- Post assembly: `lib/upload/post-utils.ts` (permlink, tags, metadata, broadcast)
+### Data Hooks
+1. Create in `lib/hooks/`.
+2. Use `useQuery`/`useMutation` from `@tanstack/react-query`.
+3. Follow patterns in `useQueries.ts` for cache keys.
## Key Conventions
-
-### Styling
-- **Dark theme only** — never add light mode
-- All colors come from `lib/theme.ts` (primary=#32CD32, bg=#000000)
-- Use `StyleSheet.create()` — no inline styles, no NativeWind in practice
-- Font: FiraCode (monospace) for all text
-
-### State Management
-- **Server state:** React Query (`@tanstack/react-query`)
-- **Auth state:** React Context (`lib/auth-provider.tsx`)
-- **Notifications:** React Context (`lib/notifications-context.tsx`)
-- **Toasts:** React Context (`lib/toast-provider.tsx`)
-- **Local state:** `useState`/`useReducer`
-
-### Imports
-- Use `~/` path alias (maps to project root via tsconfig)
-- Example: `import { theme } from '~/lib/theme'`
-
-### Security Rules
-- NEVER store private keys in plaintext
-- NEVER log private keys or decrypted values
-- Always use `expo-secure-store` for sensitive data
-- Blockchain writes require `AuthSession.decryptedKey`
-
-## Provider Hierarchy (app/_layout.tsx)
-
-```
-QueryClientProvider
- └── AuthProvider
- └── NotificationProvider
- └── ToastProvider
- └── ViewportTrackerProvider
- └── (screens)
-```
+- **Dark Theme Only**: Background `#000000`, Primary `#32CD32`.
+- **Styling**: Use `StyleSheet.create()` — no inline styles, no Tailwind/NativeWind.
+- **Security**: NEVER store or log private keys in plaintext.
+- **Imports**: Use `~/` path alias (e.g., `import { theme } from '~/lib/theme'`).
## Common Gotchas
-
-1. **Version drift:** app.json, Info.plist, project.pbxproj, and package.json all have independent version numbers that must be synced manually before builds.
-
-2. **Android versionCode:** Historically stuck at 1 — must be incremented for Play Store releases.
-
-3. **newArchEnabled mismatch:** `app.json` says `false`, `ios/Podfile.properties.json` says `true`. Pick one and sync.
-
-4. **Test account in auth-provider:** Hardcoded credentials for Apple App Store review — should be removed after approval.
-
-5. **HIVE RPC nodes:** Multiple fallback nodes configured in `hive-utils.ts`. If one fails, the client retries on the next. Don't hardcode a single node.
-
-6. **Video autoplay:** Uses viewport tracking (`lib/ViewportTracker.tsx`). Videos auto-play when 60%+ visible, pause when scrolled away.
-
-7. **No test suite:** There are no automated tests in the project currently. The `scripts/` directory is empty.
-
-## Environment Setup
-
-```bash
-# Prerequisites
-node >= 18
-pnpm
-
-# Install & run
-pnpm install
-cp .env.example .env # Configure API_BASE_URL
-pnpm dev # Start Expo dev server
-
-# Build for production
-eas build --platform ios --profile production
-eas build --platform android --profile production
-```
-
-## API Dependencies
-- `https://api.skatehive.app/api/v1` — SkateHive backend
-- `https://api.skatehive.app/api/v2/leaderboard` — Leaderboard data
-- `https://api.skatehive.app/api/transcode/status` — Video transcoding service
-- `https://images.hive.blog` — HIVE image CDN
-- HIVE RPC nodes (multiple, with failover)
+1. **Version Drift**: `app.json`, `Info.plist`, `project.pbxproj`, and `package.json` must be synced manually.
+2. **Native Modules**: This is a **Bare Workflow** project; native changes require `pnpm prebuild` or EAS.
+3. **RPC Nodes**: Multiple fallback nodes are configured in `hive-utils.ts`.
+5. **React Lists**: Always ensure every child in a list (e.g., `map()` or `FlatList`) has a unique `key` prop to prevent standard React warnings.
+6. **New Architecture**: You can ignore the warning: `setLayoutAnimationEnabledExperimental is currently a no-op in the New Architecture.`
diff --git a/CLAUDE.md b/CLAUDE.md
index 9b7fef0..0bc83f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,151 +1,68 @@
-# SkateHive Mobile App
-
-## What Is This?
-A skateboarding community mobile app built on the HIVE blockchain. Users create posts with photos/videos, vote, comment, and earn crypto rewards. Dark theme only, green accent (#32CD32).
-
-## Tech Stack
-- **Framework:** React Native + Expo SDK 54 (bare workflow — has `ios/` and `android/` dirs)
-- **Router:** expo-router (file-based, typed routes)
-- **Language:** TypeScript (strict)
-- **State:** React Query (server), React Context (auth, notifications, toast)
-- **Blockchain:** @hiveio/dhive (HIVE)
-- **Storage:** expo-secure-store (encrypted keys)
-- **Package Manager:** pnpm
-- **Font:** FiraCode (monospace)
-
-## Quick Start
-```bash
-pnpm install
-pnpm dev # Expo dev server (clears cache)
-pnpm ios # Run on iOS
-pnpm android # Run on Android
-```
+# SkateHive Mobile App – Developer Guide
+
+## Technical Stack
+- **Framework**: React Native + Expo SDK 54 (Bare Workflow)
+- **Navigation**: Expo Router (Typed, file-based)
+- **Language**: TypeScript (Strict)
+- **State Management**: React Query (Server), Context API (Auth, Notifications, Toast)
+- **Blockchain**: `@hiveio/dhive` (HIVE)
+- **Storage**: `expo-secure-store` (Encrypted keys)
+- **Styling**: `StyleSheet` (Dark theme only, Primary: `#32CD32`)
+
+## Core Commands
+- **Install**: `pnpm install`
+- **Development**: `pnpm dev` (Starts Metro with cache clear)
+- **iOS/Android**: `pnpm ios` / `pnpm android`
+- **Build**: `eas build --platform [ios|android] --profile production`
## Project Structure
-```
-app/ # Expo Router screens
- _layout.tsx # Root layout — wraps all providers
- index.tsx # Welcome/splash screen
- login.tsx # Auth screen
- (tabs)/ # Tab navigation (protected)
- feed.tsx # Home feed (trending/following)
- videos.tsx # Video gallery
- create.tsx # Post creation
- leaderboard.tsx # Community rankings
- profile.tsx # User profile (?username= param)
- notifications.tsx # Hidden tab (accessed via profile)
- conversation.tsx # Thread/reply detail view
-
-lib/ # Core business logic
- auth-provider.tsx # AuthContext — login, logout, session, multi-account
- hive-utils.ts # All blockchain operations (vote, comment, follow, etc.)
- secure-key.ts # AES encryption/decryption for private keys
- constants.ts # API URLs, community tag, app name
- theme.ts # Color palette, spacing, fonts, radii
- types.ts # TypeScript interfaces (Post, AuthSession, etc.)
- api.ts # REST API calls
- utils.ts # Helpers
- hooks/ # Custom hooks
- useQueries.ts # React Query wrappers (feed, balance, rewards)
- useSnaps.ts # Paginated post loading with deduplication
- useHiveAccount.ts # Account data from blockchain
- useVoteValue.ts # Vote power estimation
- useNotifications.ts # Notification fetching
- useBlockchainWallet.ts # Wallet balance
- useReplies.ts # Comment thread loading
- upload/ # Media upload pipeline
- image-upload.ts # HEIC->JPEG, sign with posting key, upload to images.hive.blog
- video-upload.ts # Upload to transcoding worker -> IPFS CID
- post-utils.ts # Permlink generation, metadata, broadcast
-
-components/ # UI components
- Feed/ # PostCard, Feed, VideoWithAutoplay, Conversation, etc.
- auth/ # AuthScreen, LoginForm, StoredUsersView
- ui/ # Button, Text, Input, Card, Toast, VotingSlider, etc.
- Leaderboard/ # Leaderboard display
- Profile/ # FollowersModal
- SpectatorMode/ # Read-only mode banners
- notifications/ # NotificationsScreen, NotificationItem
- markdown/ # EnhancedMarkdownRenderer
-```
-
-## Key Architecture Decisions
-
-### Authentication
-- Two methods: **PIN** (PBKDF2 -> AES) and **Biometric** (Face ID/Touch ID)
-- Private keys are NEVER stored in plaintext — encrypted in SecureStore
-- Decrypted key lives in memory only (`AuthSession.decryptedKey`)
-- Auto-logout after 1 hour of inactivity
-- Multi-account support with quick-switch
-- "Spectator Mode" for read-only browsing without login
-
-### HIVE Blockchain
-- Community: `hive-173115` (SkateHive)
-- Snaps container author: `peak.snaps`
-- Multiple RPC nodes with failover (deathwing, techcoderx, hive.blog, anyx, arcange, 3speak)
-- All blockchain writes require the user's decrypted posting key
-
-### Media Upload Pipeline
-1. **Images:** Convert HEIC->JPEG -> SHA256 hash -> sign with posting key -> upload to `images.hive.blog`
-2. **Videos:** Upload to dynamic transcoding worker (from `api.skatehive.app/api/transcode/status`) -> returns IPFS CID
-3. **Posts:** Compose markdown body + media URLs -> broadcast `comment` op to HIVE
-
-### Data Flow
-- Feed loads via `useSnaps()` -> `getSnapsContainers()` -> `getContentReplies()` -> filter by community tag
-- React Query caches with 1min stale time, 24h GC, 2 retries
-- Video autoplay triggered by viewport tracking (60%+ visible)
-
-## API Endpoints
-- `https://api.skatehive.app/api/v1` — main REST API
-- `https://api.skatehive.app/api/v2/leaderboard` — leaderboard
-- `https://api.skatehive.app/api/transcode/status` — video transcoding service discovery
-- `https://images.hive.blog` — image hosting (HIVE ecosystem)
-
-## Theme
-- Background: `#000000` (black)
-- Primary: `#32CD32` (lime green)
-- Border: `#333333`
-- Muted: `#999999`
-- Danger: `#FF3B30`
-- All defined in `lib/theme.ts`
-
-## Versioning Checklist (before `eas build`)
-Version must be updated in ALL 4 places:
-1. `app.json` → `expo.version` + `expo.ios.buildNumber` + `expo.android.versionCode`
-2. `ios/skatehive/Info.plist` → `CFBundleShortVersionString` + `CFBundleVersion`
-3. `ios/skatehive.xcodeproj/project.pbxproj` → `MARKETING_VERSION` + `CURRENT_PROJECT_VERSION`
-4. `package.json` → `version`
-
-> `eas.json` has `appVersionSource: "local"` so native files do NOT auto-sync.
-
-## Build & Deploy
-```bash
-eas build --platform ios --profile production
-eas build --platform android --profile production
-eas submit --platform ios
-eas submit --platform android
-```
-- iOS App Store ID: `6751173076`
-- iOS bundle: `com.bgrana.skatehive`
-- Android package: `com.skatehive.app`
-
-## Coding Conventions
-- TypeScript everywhere; interfaces in `lib/types.ts`
-- Functional components with hooks
-- `async/await` with try/catch
-- Import paths use `~/` alias (maps to project root)
-- StyleSheet.create for all styles (no NativeWind despite INSTRUCTIONS.md mentioning it)
-- Haptic feedback on interactive elements via `expo-haptics`
-- Use `theme` object from `lib/theme.ts` for all colors/spacing
-
-## Known Issues
-- Android `versionCode` stuck at 1 while iOS `buildNumber` is at 16+
-- `newArchEnabled` contradicts: `app.json` says false, `Podfile.properties.json` says true
-- Test account credentials hardcoded in `lib/auth-provider.tsx` (for Apple review)
-- Security fallbacks in `lib/secure-key.ts`: Math.random fallback, 5000 PBKDF2 iterations
-
-## Environment Variables
-See `.env.example`:
-- `API_BASE_URL` — backend API
-- `LOADING_EFFECT` — skeleton/matrix loading style
-- `SNAPS_CONTAINER_AUTHOR`, `COMMUNITY_TAG`, `MODERATOR_PUBLIC_KEY`
+- `app/`: Expo Router screens
+- `app/(tabs)/`: Primary navigation tabs (Feed, Videos, Create, Leaderboard, Profile)
+- `lib/`: Business logic, hooks, and utils
+ - `auth-provider.tsx`: Session and multi-account management
+ - `hive-utils.ts`: All blockchain operations
+ - `secure-key.ts`: AES encryption (PBKDF2)
+ - `theme.ts`: Central design tokens
+ - `hooks/`: React Query wrappers (`useQueries.ts`, `useSnaps.ts`)
+- `components/`: UI components (Feed, auth, ui, Profile)
+
+## Development Patterns
+
+### Code Style
+- **TypeScript**: Define all interfaces in `lib/types.ts`.
+- **Components**: Functional components with hooks.
+- **Styling**: Use `lib/theme.ts` tokens with `StyleSheet.create`. No inline styles.
+
+### Authentication & Security
+- Private keys must NEVER be stored in plaintext.
+- Use `AuthSession.decryptedKey` from `useAuth()` for blockchain operations.
+- Biometric and PIN authentication required for sensitive actions.
+
+### Data Fetching
+- Use `@stack/react-query` for all server state.
+- Define cache keys in `lib/hooks/useQueries.ts`.
+- Default stale time: 1 minute.
+
+## Build & Versioning
+Update version in 4 places before `eas build`:
+1. `app.json`: `expo.version`, `ios.buildNumber`, `android.versionCode`
+2. `ios/skatehive/Info.plist`: `CFBundleShortVersionString`, `CFBundleVersion`
+3. `ios/skatehive.xcodeproj/project.pbxproj`: `MARKETING_VERSION`, `CURRENT_PROJECT_VERSION`
+4. `package.json`: `version`
+
+## Error Handling
+- Use custom error classes defined in `lib/hive-utils.ts` and `lib/auth-provider.tsx` (`AuthError`, `InvalidKeyError`, etc.).
+- Always provide user-friendly error messages via `ToastProvider`.
+- Log detailed errors to console for debugging but avoid logging private data.
+
+## Accessibility
+- Use `accessibilityLabel` for all interactive elements.
+- Ensure high contrast ratios (Black/Green theme provides this by default).
+- Support dynamic font sizes where possible.
+
+## Known Gotchas
+- **Bare Workflow**: Changes in `ios/` or `android/` folders require `pnpm prebuild` if not using EAS.
+- **New Architecture**: `newArchEnabled` is currently `false` in `app.json`. You can ignore the warning: `setLayoutAnimationEnabledExperimental is currently a no-op in the New Architecture.`
+- **React Lists**: Always ensure every child in a list (e.g., `map()` or `FlatList`) has a unique `key` prop to prevent React warnings.
+- **RPC Failover**: `hive-utils.ts` handles multiple fallback nodes automatically.
+- **Apple Review**: Test account with simple password logic exists in `lib/auth-provider.tsx`.
diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md
deleted file mode 100644
index 272f301..0000000
--- a/INSTRUCTIONS.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# MyCommunity App – Project Instructions
-
-## Overview
-MyCommunity App is a React Native application built with Expo, designed for community engagement and social interaction on the HIVE blockchain. It features secure authentication, decentralized content, and a modern UI/UX using NativeWind (TailwindCSS).
-
-## Technology Stack
-- React Native (Expo)
-- TypeScript (strict mode)
-- NativeWind/TailwindCSS for styling
-- Expo Router for navigation
-- React Query for data fetching
-- React Context for global state
-- Expo SecureStore for sensitive data
-- @hiveio/dhive for HIVE blockchain integration
-
-## Project Structure
-- `app/` – Expo Router screens (tabs, onboarding, settings)
-- `assets/` – Images and videos
-- `components/` – UI and feature components (ui, auth, feed, leaderboard, etc.)
-- `lib/` – Core logic (api, auth-provider, hive-utils, hooks, icons, types, utils)
-
-## Coding Conventions
-- Use TypeScript everywhere; define interfaces in `lib/types.ts`
-- Use async/await for async code, with try/catch and loading states
-- Functional components with hooks; keep components small and focused
-- Use NativeWind classes via `className` for styling; combine with `clsx` or `tailwind-merge` for dynamic styles
-- Use React Query for server state, React Context for global state, and useState/useReducer for local state
-- Use Expo Router for navigation; follow file-based routing in `app/`
-- Use AuthContext from `lib/auth-provider.tsx` for authentication; store sensitive keys in SecureStore
-- Use API functions from `lib/api.ts` and React Query for data fetching
-
-## Best Practices
-- DRY: Avoid code duplication
-- Write meaningful comments for complex logic
-- Break down large components
-- Handle loading and error states gracefully (see `components/ui/LoadingScreen.tsx`)
-- Optimize performance (React.memo, useCallback, useMemo)
-- Follow HIVE blockchain best practices for key management and transactions
-
-## Testing
-- Test critical components and utilities
-- Test authentication and mobile-specific flows
-- Implement UI tests for key user flows
-
-## Accessibility
-- Use accessibility labels and aria attributes
-- Test with different font sizes and contrast
-- Follow React Native accessibility guidelines
-
-## Environment & Setup
-- Node.js v18+
-- pnpm as package manager
-- Expo SDK 52+
-- Android 5.0+ and iOS 13+ supported
-
-### Install & Run
-1. Install dependencies: `pnpm install`
-2. Set up `.env` with `API_BASE_URL` and `HIVE_NODE`
-3. Start development: `pnpm start` (or `pnpm ios`, `pnpm android`, `pnpm web`)
-
-### Build
-- Use EAS Build for production: `eas build --platform ios|android --profile production`
-
-## HIVE Blockchain
-- Use `@hiveio/dhive` for blockchain actions (see `lib/hive-utils.ts`)
-- Store keys securely; never expose private keys
-- Handle blockchain errors and retries
-
-## Error Handling
-- Use custom error classes (see `lib/auth-provider.tsx`)
-- Show user-friendly error messages
-- Log errors for debugging
-
----
-For more details, see `.github/copilot-instructions.md` and `README.md`.
diff --git a/README.md b/README.md
index a64c4cd..852f5e4 100644
--- a/README.md
+++ b/README.md
@@ -1,221 +1,107 @@
-# MyCommunity App 🚀
+# SkateHive Mobile App 🛹🚀
-A powerful React Native mobile application that empowers communities through the HIVE blockchain. MyCommunity App combines social networking features with decentralized rewards, secure authentication, and a modern user interface to create a seamless community experience.
+The official mobile application for **SkateHive**, the premier skateboarding community on the HIVE blockchain. This app allows skaters to share content (photos/videos), vote on posts, interact with the community, and earn crypto rewards (HIVE/HBD) in a truly decentralized environment.
## 📱 Overview
-MyCommunity App is built using the latest React Native and Expo technologies, offering a cross-platform solution with native performance. The application connects to the HIVE blockchain, allowing users to interact with decentralized content, manage their HIVE wallets, and participate in community governance.
+SkateHive Mobile is a high-performance React Native application built with **Expo SDK 54**. It features a modern, high-contrast dark theme, secure biometric authentication, and deep integration with the HIVE blockchain.
## 🌟 Key Features
-### Content Creation & Sharing
+- **Content Creation & Sharing**:
+ - 📝 Markdown-supported posts and comments.
+ - 🖼️ Rich media support with HEIC to JPEG conversion.
+ - 🎬 Video uploads with IPFS integration.
+- **Secure Wallet & Auth**:
+ - 🔒 AES-encrypted key storage via Expo SecureStore.
+ - 🔑 PIN and Biometric (FaceID/TouchID) authentication.
+ - 💰 Real-time balance and reward tracking.
+- **Community Interaction**:
+ - ⭐ Hive reputation system.
+ - 📊 Global and community leaderboards.
+ - 💬 Threaded conversations and notifications.
+- **UI/UX**:
+ - 🌒 Pure Dark Theme (Black #000000, Lime Green #32CD32).
+ - ⚡ Fast, responsive UI with FiraCode (monospace) typography.
+ - 🎯 Viewport-aware video autoplay.
+
+## 🛠️ Technology Stack
+
+- **Core**: React Native (Expo SDK 54, Bare Workflow)
+- **Navigation**: Expo Router (File-based, Typed)
+- **Language**: TypeScript (Strict)
+- **State Management**: React Query (Server), React Context (Auth/Toast/Notifications)
+- **Blockchain**: `@hiveio/dhive` (HIVE RPC integration)
+- **Styling**: `StyleSheet` (Standard themed styles)
+- **Storage**: `expo-secure-store`, `expo-file-system`
-- 📝 Long-form articles with Markdown support
-- 📱 Short posts for quick updates
-- 🖼️ Rich media support (images & videos)
-- 🎬 IPFS video integration
-- 📊 Post analytics and earnings tracking
-
-### Secure Wallet Integration
-
-- 🔒 Encrypted credential storage
-- 💰 HIVE wallet integration
-- 🎁 Easy community rewards distribution
-- 📈 Real-time payout tracking
-- 🔐 Secure voting mechanism
-
-### Community Features
-
-- 👥 Community building tools
-- 🏷️ Custom tags and categories
-- 💬 Interactive discussions
-- ⭐ Reputation system
-- 📊 Community analytics
-
-### UI/UX
-
-- 🌓 Dark/Light theme toggle
-- 📱 Native mobile experience
-- ⚡ Fast and responsive interface
-- 🎨 Modern design language
-
-## 🛠️ Project Structure
-
-The project follows a clean, modular architecture:
-
-```
-mycommunity-app/
-├── app/ # Expo Router screens and navigation
-│ ├── (tabs)/ # Main tab screens
-│ ├── (onboarding)/ # Onboarding flows
-│ └── _layout.tsx # Root navigation layout
-├── assets/ # Static assets (images, videos)
-├── components/ # Reusable UI components
-│ ├── auth/ # Authentication components
-│ ├── Feed/ # Feed-related components
-│ ├── ui/ # Base UI components
-│ └── Leaderboard/ # Leaderboard components
-├── lib/ # Core utilities and business logic
-│ ├── hooks/ # Custom React hooks
-│ ├── icons/ # Icon components
-│ ├── api.ts # API integration
-│ ├── auth-provider.tsx # Authentication context
-│ ├── hive-utils.ts # HIVE blockchain utilities
-│ └── types.ts # TypeScript type definitions
-└── ...configuration files
-```
-
-### Key Files
-
-- `app/_layout.tsx`: Root navigation and providers setup
-- `lib/auth-provider.tsx`: Authentication logic and secure storage
-- `lib/api.ts`: API integration functions
-- `components/ui/`: Reusable UI components built with NativeWind
-
-## 📋 Prerequisites
+## 🚀 Getting Started
-- [Node.js](https://nodejs.org/) (v18 or newer)
-- [pnpm](https://pnpm.io/) package manager
-- [Expo CLI](https://docs.expo.dev/workflow/expo-cli/) (optional, but recommended)
-- For iOS development: macOS with Xcode
-- For Android development: Android Studio and SDK
+### Prerequisites
-## 🚀 Getting Started
+- [Node.js](https://nodejs.org/) (v18+)
+- [pnpm](https://pnpm.io/)
+- [EAS CLI](https://docs.expo.dev/build/introduction/) (for builds)
### Installation
1. Clone the repository:
+ ```bash
+ git clone https://github.com/SkateHive/mobileapp-skatehive.git
+ cd mobileapp-skatehive
+ ```
+2. Install dependencies:
+ ```bash
+ pnpm install
+ ```
+3. Set up environment:
+ ```bash
+ cp .env.example .env
+ # Configure API_BASE_URL and other vars
+ ```
+
+### Running Locally
```bash
-git clone https://github.com/SkateHive/mobileapp.git
-cd mobileapp
-```
-
-2. Install dependencies with pnpm:
-
-```bash
-pnpm install
-```
-
-3. Set up environment variables (if needed):
-
-Create a `.env` file in the project root with your configuration:
-
-```
-API_BASE_URL=your_api_url
-HIVE_NODE=your_preferred_node
-```
-
-### Running the App
-
-#### Development Mode
-
-Run the app in development mode with hot reloading:
-
-```bash
-# Start the development server with Metro bundler
-pnpm dev
-
-# Run on iOS simulator
-pnpm ios
-
-# Run on Android emulator
-pnpm android
-
-# Run on web browser
-pnpm web
+pnpm dev # Start Expo Metro bundler
+pnpm ios # Run on iOS simulator
+pnpm android # Run on Android emulator
```
-#### Using a Physical Device
-
-To run on a physical device:
-
-1. Install the Expo Go app on your device
-2. Make sure your device is on the same network as your development machine
-3. Scan the QR code displayed in the terminal with your camera app (iOS) or Expo Go app (Android)
-
## 📦 Building for Production
-This project uses [EAS Build](https://docs.expo.dev/build/introduction/) for creating production-ready builds:
+This project uses [EAS Build](https://docs.expo.dev/build/introduction/):
```bash
-# Install EAS CLI if not already installed
-npm install -g eas-cli
-
-# Log in to your Expo account
-eas login
-
-# Configure your build profiles (if needed)
-eas build:configure
-
-# Build for internal testing (preview)
-eas build --platform ios --profile preview
-eas build --platform android --profile preview
-
-# Build for production
eas build --platform ios --profile production
eas build --platform android --profile production
```
-## 📱 HIVE Blockchain Integration
+> [!IMPORTANT]
+> **Versioning Checklist**: Update version in `app.json`, `ios/skatehive/Info.plist`, `ios/skatehive.xcodeproj/project.pbxproj`, and `package.json` before building.
-MyCommunity App integrates with the HIVE blockchain for:
+## 🔒 Security & Key Storage
-- User authentication using HIVE account credentials
-- Content storage and retrieval
-- Rewards distribution and tracking
-- Voting and social interactions
+Private keys are never stored in plaintext. They are encrypted using AES (PBKDF2 key derivation) and stored in the device's SecureStore.
-The integration is handled through the `@hiveio/dhive` library with secure storage of user credentials using Expo SecureStore.
+### Production Security Checklist
-## 🔧 Troubleshooting
-
-### Common Issues
-
-#### Metro Bundler Issues
-
-If you encounter issues with the Metro bundler:
-
-```bash
-# Clear Metro cache
-pnpm clean
-pnpm dev -c
-```
+- [ ] Ensure PBKDF2 iterations are set to 100,000+ (no dev overrides).
+- [ ] Verify `expo-crypto` is used for all Salt/IV generation (remove `Math.random` fallbacks).
+- [ ] Disable all debug logs and test credentials in `lib/auth-provider.tsx`.
+- [ ] Test on a real device using a production build (EAS).
## 🤝 Contributing
-Contributions are welcome! Please feel free to submit a Pull Request.
-
-1. Fork the repository
-2. Create your feature branch: `git checkout -b feature/amazing-feature`
-3. Commit your changes: `git commit -m 'Add some amazing feature'`
-4. Push to the branch: `git push origin feature/amazing-feature`
-5. Open a Pull Request
-
-## 🔐 Security
-
-- Encrypted local storage
-- Secure key management
-- Private key never leaves the device
-- Regular security audits
-
-## 💎 Powered by HIVE
-
-Built on the HIVE blockchain, enabling:
-
-- Decentralized content storage
-- Community rewards
-- Transparent monetization
-- Censorship resistance
-
-## 🔗 Links
+Contributions are welcome! Please follow these steps:
-- [HIVE Blockchain](https://hive.io/)
-- [Expo Documentation](https://docs.expo.dev/)
-- [React Native Documentation](https://reactnative.dev/docs/getting-started)
+1. Fork the repo and create your feature branch.
+2. Ensure TypeScript types are correctly defined in `lib/types.ts`.
+3. Use `async/await` with proper try/catch blocks for blockchain operations.
+4. Submit a Pull Request with a clear description of changes.
---
- Made with ❤️ for the HIVE community
+ Built on the HIVE Blockchain | Empowering Skaters Globally
diff --git a/SECURE_KEY_PRODUCTION_README.md b/SECURE_KEY_PRODUCTION_README.md
deleted file mode 100644
index 12e04ce..0000000
--- a/SECURE_KEY_PRODUCTION_README.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Secure Key Storage: Production Checklist
-
-This document explains the temporary development workarounds in place for Expo Go and what you must do before releasing your app to production.
-
-## Current Development Workarounds
-
-- **Salt/IV Generation:**
- - In development (`__DEV__`), if `expo-crypto` fails, a fallback using `Math.random` is used for salt/IV generation. This is insecure and only for local testing in Expo Go.
-- **Encryption/Decryption:**
- - In development (`__DEV__`), `encryptKey` and `decryptKey` use a mock implementation (base64 encoding/decoding) instead of real AES encryption. This is insecure and only for local testing in Expo Go.
-- **Key Storage:**
- - SecureStore keys use an underscore (`userkey_username`) to avoid invalid character errors on iOS/Android.
-
-
-## What To Do For Production
-
-### 1. Remove All Development-Only Code and Fallbacks
-- Remove all `__DEV__` or development-only code, including:
- - The fallback for salt/IV generation using `Math.random`. Only use `expo-crypto` for salt/IV in production.
- - The mock (base64) logic in `encryptKey` and `decryptKey`. Only use real AES encryption/decryption (`crypto-js`).
- - Any test or debug code, including the `testSecureStore` function and all debug logs.
-- **PBKDF2 Iterations:**
- - In development, PBKDF2 may use a low iteration count (e.g., 1,000) for speed. In production, always use a high iteration count (e.g., 100,000 or more). Remove any `__DEV__` or development-only logic that lowers the iteration count.
-
-### 2. Test on a Real Device with a Production Build
-- Expo Go does not support all native modules. Build your app with EAS (`eas build`) or use a custom dev client (`eas dev-client`) to test the real production code on a real device.
-
-### 3. Confirm Only Secure, Native Crypto is Used
-- Double-check that no mock/fallback code or insecure logic is present in the production build. Only use `expo-crypto` and `crypto-js` AES for all cryptographic operations.
-
-### 4. Confirm All Sensitive Data is Encrypted and Stored Securely
-- Ensure all private keys, PINs, and other sensitive data are encrypted with strong, random salt/IV and stored only in SecureStore.
-- Never log or expose private keys, PINs, or secrets in production.
-
-### 5. Key Storage Format
-- Continue using the `userkey_username` format for SecureStore keys (underscore, not colon).
-
-### 6. Remove All Debug/Test Logs and Functions
-- Delete or comment out any test/debug code and all debug logs before release.
-
-### 7. Security Review
-- Contact your security lead for a final review before publishing to the app store.
-
-## Summary Table
-| Area | Development (Expo Go) | Production (EAS/Standalone) |
-|---------------------|-------------------------------|-------------------------------------|
-| Salt/IV Generation | `Math.random` fallback | Only `expo-crypto` |
-| Encryption | Mock (base64) | Real AES (crypto-js) |
-| PBKDF2 Iterations | 1,000 (fast, insecure) | 100,000+ (secure) |
-| SecureStore Key | `userkey_username` | `userkey_username` |
-| Debug/Test Code | Present | Remove before release |
-
-## Final Checklist Before Release
-
-- [ ] Remove all development-only code and fallbacks (including salt/IV fallback, mock encryption, PBKDF2 dev override)
-- [ ] Set PBKDF2 iterations to 100,000+ (remove any dev override)
-- [ ] Test on a real device with a production build (EAS or custom dev client)
-- [ ] Confirm only secure, native crypto is used (expo-crypto, crypto-js AES)
-- [ ] Confirm all sensitive data is encrypted and stored securely in SecureStore
-- [ ] Remove all debug/test logs and functions
-- [ ] Contact your security lead for a final review before publishing
-
----
-
-**Contact your security lead for a final review before publishing to the app store.**
diff --git a/app.json b/app.json
index de2db4f..ae705a3 100644
--- a/app.json
+++ b/app.json
@@ -1,6 +1,6 @@
{
"expo": {
- "name": "SkateHive",
+ "name": "Skatehive",
"slug": "skatehive-app",
"version": "1.0.2",
"orientation": "portrait",
@@ -57,17 +57,18 @@
[
"expo-camera",
{
- "cameraPermission": "SkateHive needs access to your camera to capture photos and videos of your skateboarding tricks and moments. This media will be uploaded to IPFS and shared on the Hive blockchain as part of your posts."
+ "cameraPermission": "Skatehive needs access to your camera to capture photos and videos of your skateboarding tricks and moments. This media will be uploaded to IPFS and shared on the Hive blockchain as part of your posts."
}
],
[
"expo-media-library",
{
- "photosPermission": "SkateHive needs access to your photo library to let you select existing photos and videos for your skateboarding posts. Selected media will be uploaded to IPFS and shared on the Hive blockchain.",
- "savePhotosPermission": "SkateHive needs permission to save photos to your library."
+ "photosPermission": "Skatehive needs access to your photo library to let you select existing photos and videos for your skateboarding posts. Selected media will be uploaded to IPFS and shared on the Hive blockchain.",
+ "savePhotosPermission": "Skatehive needs permission to save photos to your library."
}
],
- "expo-font"
+ "expo-font",
+ "expo-web-browser"
],
"experiments": {
"typedRoutes": true
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 41aa238..39918e9 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,9 +1,17 @@
import { Ionicons } from "@expo/vector-icons";
-import { Tabs, useRouter } from "expo-router";
+import { Tabs, useRouter, useSegments } from "expo-router";
import { StyleSheet, View, PanResponder } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
-import { useRef } from "react";
+import { useRef, useState } from "react";
import { theme } from "~/lib/theme";
+import { GlobalHeader } from "~/components/ui/GlobalHeader";
+import { SideMenu } from "~/components/ui/SideMenu";
+import { FeedFilterProvider, useFeedFilter } from "~/lib/FeedFilterContext";
+import { Pressable, Text as RNText, Modal } from "react-native";
+import { Text } from "~/components/ui/text";
+import { useAuth } from "~/lib/auth-provider";
+import useHiveAccount from "~/lib/hooks/useHiveAccount";
+import { Image } from "expo-image";
interface TabItem {
name: string;
@@ -16,7 +24,7 @@ interface TabItem {
const TAB_ITEMS: TabItem[] = [
{
name: "videos",
- title: "Videos",
+ title: "Skatehive",
icon: "home-outline",
iconFamily: "Ionicons",
},
@@ -71,10 +79,109 @@ const styles = StyleSheet.create({
shadowRadius: 8,
elevation: 8,
},
+ avatarContainer: {
+ marginBottom: -10,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ tabAvatar: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ borderWidth: 1.5,
+ },
});
+function FeedHeaderTitle() {
+ const { filter, setFilter } = useFeedFilter();
+ const [showDropdown, setShowDropdown] = useState(false);
+
+ const filters: ('Recent' | 'Following' | 'Curated' | 'Trending')[] = ['Recent', 'Following', 'Curated', 'Trending'];
+
+ return (
+
+ setShowDropdown(true)} style={{ flexDirection: 'row', alignItems: 'center' }}>
+
+ {filter}
+
+
+
+
+ setShowDropdown(false)}
+ >
+ setShowDropdown(false)}
+ >
+
+ {filters.map((f) => (
+ {
+ setFilter(f);
+ setShowDropdown(false);
+ }}
+ style={{
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ backgroundColor: filter === f ? 'rgba(50, 205, 50, 0.1)' : 'transparent',
+ borderRadius: 8,
+ marginBottom: 4,
+ }}
+ >
+
+ {f}
+
+
+ ))}
+
+
+
+
+ );
+}
+
export default function TabLayout() {
const router = useRouter();
+ const [isMenuVisible, setIsMenuVisible] = useState(false);
+ const segments = useSegments();
+ const { username } = useAuth();
+ const { hiveAccount } = useHiveAccount(username || "");
+
+ const currentTab = segments[segments.length - 1];
+ const isVideosTab = currentTab === "videos";
+ const isProfileTab = currentTab === "profile";
+
+ const userAvatarUrl = username && username !== "SPECTATOR"
+ ? (hiveAccount?.metadata?.profile?.profile_image || `https://images.hive.blog/u/${username}/avatar/small`)
+ : null;
+
+ // Determine header title based on active tab
+ const getHeaderTitle = () => {
+ const currentTab = segments[segments.length - 1];
+
+ switch (currentTab) {
+ case "videos":
+ return "Skatehive";
+ case "feed":
+ return "Skatehive";
+ case "create":
+ return "Skatehive Create";
+ case "leaderboard":
+ return "Leaderboard";
+ case "profile":
+ return "Profile";
+ default:
+ return "Skatehive";
+ }
+ };
// Create swipe gesture using PanResponder (simpler, less likely to crash)
const panResponder = useRef(
@@ -96,8 +203,17 @@ export default function TabLayout() {
).current;
return (
-
-
+
+
+ {!isVideosTab && (
+ setIsMenuVisible(true)}
+ title={getHeaderTitle()}
+ // centerComponent={currentTab === "feed" ? : undefined} // Future feature: Filter dropdown
+ showSettings={isProfileTab}
+ />
+ )}
+
+ tabBarIcon: ({ color, focused }: { color: string; focused: boolean }) =>
tab.isCenter ? (
),
...(tab.name === "profile" && {
@@ -141,7 +259,7 @@ export default function TabLayout() {
params: {},
},
}),
- }}
+ } as any}
/>
))}
@@ -153,9 +271,20 @@ export default function TabLayout() {
title: "Notifications",
}}
/>
+
+ {/* Hidden search tab - accessible from header */}
+
-
+ setIsMenuVisible(false)} />
+
+
);
}
@@ -163,8 +292,21 @@ function TabBarIcon(props: {
name: string;
color: string;
iconFamily: "Ionicons";
+ avatarUrl?: string | null;
}) {
- const { name, color } = props;
+ const { name, color, avatarUrl } = props;
+
+ if (avatarUrl) {
+ return (
+
+
+
+ );
+ }
return (
- {/* Header */}
- Create
+ {/* Header Removed for more space */}
{/* Content Input */}
@@ -400,7 +399,7 @@ export default function CreatePost() {
{mediaType === "image" ? (
-
+
) : mediaType === "video" ? (
hasVideoInteraction ? (
@@ -454,13 +453,7 @@ const styles = StyleSheet.create({
padding: theme.spacing.md,
},
headerText: {
- fontSize: theme.fontSizes.xxxl,
- fontFamily: theme.fonts.bold,
- color: theme.colors.text,
- marginLeft: theme.spacing.md,
- marginTop: theme.spacing.xxl,
- marginBottom: theme.spacing.sm,
- paddingTop: theme.spacing.md,
+ display: 'none',
},
card: {
backgroundColor: theme.colors.card,
diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx
index 9574c47..b9c2437 100644
--- a/app/(tabs)/profile.tsx
+++ b/app/(tabs)/profile.tsx
@@ -20,12 +20,20 @@ import { ProfileSpectatorInfo } from "~/components/SpectatorMode/ProfileSpectato
import { PostCard } from "~/components/Feed/PostCard";
import { LoadingScreen } from "~/components/ui/LoadingScreen";
import { FollowersModal } from "~/components/Profile/FollowersModal";
+import { useFocusEffect } from '@react-navigation/native';
import { EditProfileModal } from "~/components/Profile/EditProfileModal";
+
+const { width } = Dimensions.get('window');
import { theme } from "~/lib/theme";
import useHiveAccount from "~/lib/hooks/useHiveAccount";
-import { useUserComments } from "~/lib/hooks/useUserComments";
+import { useToast } from "~/lib/toast-provider";
+import { useUserComments } from '~/lib/hooks/useUserComments';
+import { useScrollLock } from '~/lib/ScrollLockContext';
+import { ConversationDrawer } from '~/components/Feed/ConversationDrawer';
+import type { Discussion } from '@hiveio/dhive';
import { extractMediaFromBody } from "~/lib/utils";
import { GridVideoTile } from "~/components/Profile/GridVideoTile";
+import { VideoPlayer } from '~/components/Feed/VideoPlayer';
const GRID_COLS = 3;
const GRID_GAP = 2;
@@ -50,7 +58,7 @@ const SkeletonTile = React.memo(({ size, delay }: { size: number; delay: number
});
const GridSkeleton = ({ tileSize }: { tileSize: number }) => (
-
+
{Array.from({ length: 12 }).map((_, i) => (
))}
@@ -62,6 +70,7 @@ const skeletonStyles = StyleSheet.create({
flexDirection: 'row',
flexWrap: 'wrap',
gap: GRID_GAP,
+ justifyContent: 'flex-start',
},
});
@@ -99,17 +108,25 @@ function countryToFlag(location: string): string {
for (const [key, flag] of Object.entries(map)) {
if (loc.includes(key)) return flag;
}
- return '🌍';
+ return '📍';
}
export default function ProfileScreen() {
const { username: currentUsername, logout } = useAuth();
+ const { isScrollLocked } = useScrollLock();
const params = useLocalSearchParams();
const [followersModalVisible, setFollowersModalVisible] = useState(false);
const [editProfileVisible, setEditProfileVisible] = useState(false);
const [settingsMenuVisible, setSettingsMenuVisible] = useState(false);
const [modalType, setModalType] = useState<'followers' | 'following' | 'muted'>('followers');
+ const [conversationPost, setConversationPost] = useState(null);
const [profileTab, setProfileTab] = useState<'grid' | 'posts'>('grid');
+ const [isFollowing, setIsFollowing] = useState(false);
+ const [isMuted, setIsMuted] = useState(false);
+ const [isFollowLoading, setIsFollowLoading] = useState(false);
+ const [isMuteLoading, setIsMuteLoading] = useState(false);
+ const { followingList, mutedList, updateUserRelationship, session, refreshUserRelationships } = useAuth();
+ const { showToast } = useToast();
// Reset UI state when navigating between profiles
const profileUsername = (params.username as string) || currentUsername;
@@ -118,8 +135,95 @@ export default function ProfileScreen() {
setEditProfileVisible(false);
setSettingsMenuVisible(false);
setProfileTab('grid');
+ setIsFollowLoading(false);
}, [profileUsername]);
+ // Force refresh relationships when visiting a profile to ensure following status is accurate
+ useEffect(() => {
+ if (typeof currentUsername === 'string' && currentUsername !== "SPECTATOR") {
+ console.log(`[Profile Sync] Forcing relationship refresh for @${currentUsername} while viewing @${profileUsername}`);
+ refreshUserRelationships().catch(console.error);
+ }
+ }, [profileUsername, currentUsername, refreshUserRelationships]);
+
+ // Sync following status with global list
+ useEffect(() => {
+ if (followingList && profileUsername) {
+ const profileLower = profileUsername.toLowerCase();
+ const following = followingList.some((u: string) => u.toLowerCase() === profileLower);
+
+ console.log(`[Profile Sync] Checking if @${profileLower} is in followingList: ${following}`);
+ console.log(` - Current followingList size: ${followingList.length}`);
+
+ if (!following && followingList.length < 10) {
+ console.log(" - followingList (first 10):", followingList.slice(0, 10));
+ }
+
+ setIsFollowing(following);
+ }
+
+ if (mutedList && profileUsername) {
+ const profileLower = profileUsername.toLowerCase();
+ const muted = mutedList.some((u: string) => u.toLowerCase() === profileLower);
+ setIsMuted(muted);
+ }
+ }, [followingList, mutedList, profileUsername]);
+
+ const handleFollow = async () => {
+ if (!profileUsername || profileUsername === "SPECTATOR") return;
+
+ if (!currentUsername || currentUsername === "SPECTATOR" || !session?.decryptedKey) {
+ showToast('Please login first', 'error');
+ return;
+ }
+
+ try {
+ setIsFollowLoading(true);
+ const isCurrentlyFollowing = followingList.some((u: string) => u.toLowerCase() === profileUsername.toLowerCase());
+ const action = isCurrentlyFollowing ? '' : 'blog'; // '' unsets relationship (unfollow)
+
+ const success = await updateUserRelationship(profileUsername, action);
+ if (success) {
+ showToast(isCurrentlyFollowing ? `Unfollowed @${profileUsername}` : `Following @${profileUsername}`, 'success');
+ } else {
+ showToast(`Failed to ${isCurrentlyFollowing ? 'unfollow' : 'follow'} user`, 'error');
+ }
+ } catch (error) {
+ showToast('Error updating relationship', 'error');
+ } finally {
+ setIsFollowLoading(false);
+ }
+ };
+
+ const handleMute = async () => {
+ if (!profileUsername || profileUsername === "SPECTATOR") return;
+
+ if (!currentUsername || currentUsername === "SPECTATOR" || !session?.decryptedKey) {
+ showToast('Please login first', 'error');
+ return;
+ }
+
+ try {
+ setIsMuteLoading(true);
+ const action = isMuted ? '' : 'ignore';
+
+ const success = await updateUserRelationship(profileUsername, action);
+ if (success) {
+ showToast(isMuted ? `Unmuted @${profileUsername}` : `Muted @${profileUsername}`, 'success');
+ // If we just muted them, we should also unfollow if we following
+ if (action === 'ignore' && isFollowing) {
+ setIsFollowing(false);
+ }
+ } else {
+ showToast(`Failed to ${isMuted ? 'unmute' : 'mute'} user`, 'error');
+ }
+ } catch (error) {
+ showToast('Error updating relationship', 'error');
+ } finally {
+ setIsMuteLoading(false);
+ }
+ };
+
const { hiveAccount, isLoading: isLoadingProfile, error } = useHiveAccount(profileUsername);
const {
posts: userPosts,
@@ -127,7 +231,7 @@ export default function ProfileScreen() {
loadNextPage,
hasMore,
refresh: refreshPosts,
- } = useUserComments(profileUsername);
+ } = useUserComments(profileUsername, mutedList);
// Get thumbnail for a post — checks multiple sources
const getPostThumbnail = useCallback((post: any): string | null => {
@@ -199,22 +303,11 @@ export default function ProfileScreen() {
);
// Auto-load more when grid doesn't have enough items to fill the screen
- // A 3-col grid needs ~15 items (5 rows) to be scrollable
- const MIN_GRID_ITEMS = 15;
- useEffect(() => {
- if (
- profileTab === 'grid' &&
- !isLoadingPosts &&
- hasMore &&
- gridPosts.length < MIN_GRID_ITEMS &&
- userPosts.length > 0
- ) {
- loadNextPage();
- }
- }, [profileTab, isLoadingPosts, hasMore, gridPosts.length, userPosts.length, loadNextPage]);
+ // REMOVED: This was causing infinite loops and crashes (OOM) on profiles with many media-less posts.
+ // The user can still scroll down to trigger loadNextPage via onEndReached.
// Render grid item
- const tileSize = (SCREEN_WIDTH - GRID_GAP * (GRID_COLS - 1)) / GRID_COLS;
+ const tileSize = (SCREEN_WIDTH - (GRID_GAP * (GRID_COLS - 1))) / GRID_COLS - 0.5;
const renderGridItem = useCallback(({ item }: { item: any }) => {
const media = extractMediaFromBody(item.body);
@@ -226,7 +319,7 @@ export default function ProfileScreen() {
router.push({ pathname: '/conversation', params: { author: item.author, permlink: item.permlink } })}
+ onPress={() => setConversationPost(item)}
/>
);
}
@@ -236,7 +329,7 @@ export default function ProfileScreen() {
return (
router.push({ pathname: '/conversation', params: { author: item.author, permlink: item.permlink } })}
+ onPress={() => setConversationPost(item)}
>
{thumb ? (
(
+ const renderProfileHeader = () => {
+ return (
{/* Profile Section */}
@@ -391,9 +485,55 @@ export default function ProfileScreen() {
)}
+ {/* Username + Action Buttons */}
+
+ @{profileUsername}
+
+ {currentUsername && profileUsername !== currentUsername && profileUsername !== "SPECTATOR" && (
+ <>
+
+ {isFollowLoading ? (
+
+ ) : (
+
+ {isFollowing ? 'Unfollow' : 'Follow'}
+
+ )}
+
- {/* Username */}
- @{profileUsername}
+
+ {isMuteLoading ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+
{/* Stats + flag inline */}
@@ -463,7 +603,8 @@ export default function ProfileScreen() {
)}
- );
+ );
+ };
// Render individual post item
const renderPostItem = ({ item }: { item: any }) => (
@@ -471,6 +612,7 @@ export default function ProfileScreen() {
key={item.permlink}
post={item}
currentUsername={currentUsername || ''}
+ onOpenConversation={(post) => setConversationPost(post)}
/>
);
@@ -482,7 +624,7 @@ export default function ProfileScreen() {
if (!isLoadingPosts) return null;
return (
-
+
);
};
@@ -501,6 +643,7 @@ export default function ProfileScreen() {
}
showsVerticalScrollIndicator={false}
+ scrollEnabled={!isScrollLocked}
>
{renderProfileHeader()}
@@ -526,15 +669,15 @@ export default function ProfileScreen() {
) : null
}
onEndReached={hasMore ? loadNextPage : undefined}
- onEndReachedThreshold={0.8}
+ onEndReachedThreshold={0.5}
refreshControl={
}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
- initialNumToRender={12}
- maxToRenderPerBatch={9}
- windowSize={7}
+ initialNumToRender={6}
+ maxToRenderPerBatch={3}
+ windowSize={3}
contentContainerStyle={{ gap: GRID_GAP }}
/>
) : (
@@ -559,6 +702,7 @@ export default function ProfileScreen() {
}
showsVerticalScrollIndicator={false}
+ scrollEnabled={!isScrollLocked}
removeClippedSubviews={true}
initialNumToRender={5}
maxToRenderPerBatch={3}
@@ -577,6 +721,15 @@ export default function ProfileScreen() {
/>
)}
+ {/* Single shared conversation drawer */}
+ {conversationPost && (
+ setConversationPost(null)}
+ post={conversationPost}
+ />
+ )}
+
{/* Edit Profile Modal */}
{!params.username && (
Edit Profile
+ {
+ setSettingsMenuVisible(false);
+ handleMutedPress();
+ }}
+ >
+
+ Muted Users
+
+
{
@@ -702,6 +866,52 @@ const styles = StyleSheet.create({
color: theme.colors.muted,
fontFamily: theme.fonts.regular,
},
+ usernameRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: theme.spacing.md,
+ },
+ followActionBtn: {
+ paddingHorizontal: theme.spacing.md,
+ paddingVertical: theme.spacing.xxs,
+ borderRadius: theme.borderRadius.full,
+ borderWidth: 1,
+ minWidth: 80,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ followBtn: {
+ backgroundColor: theme.colors.primary,
+ borderColor: theme.colors.primary,
+ },
+ unfollowBtn: {
+ backgroundColor: 'transparent',
+ borderColor: theme.colors.border,
+ },
+ followActionBtnText: {
+ fontSize: theme.fontSizes.xs,
+ fontFamily: theme.fonts.bold,
+ },
+ followBtnText: {
+ color: theme.colors.background,
+ },
+ unfollowBtnText: {
+ color: theme.colors.text,
+ },
+ mutedActionBtn: {
+ backgroundColor: 'rgba(255, 68, 68, 0.1)',
+ borderColor: theme.colors.danger,
+ minWidth: 40,
+ },
+ unmuteActionBtn: {
+ backgroundColor: 'transparent',
+ borderColor: theme.colors.border,
+ minWidth: 40,
+ },
+ headerActionsRaw: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
statsRow: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx
new file mode 100644
index 0000000..f416421
--- /dev/null
+++ b/app/(tabs)/search.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { View, StyleSheet, TextInput } from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { Text } from "~/components/ui/text";
+import { theme } from "~/lib/theme";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+export default function SearchScreen() {
+ return (
+
+
+
+
+
+
+
+
+
+ Search is coming soon!
+
+ We're working hard to bring you the best search experience.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.background,
+ },
+ searchHeader: {
+ padding: theme.spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ searchBar: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: theme.colors.card,
+ borderRadius: theme.borderRadius.full,
+ paddingHorizontal: theme.spacing.md,
+ height: 40,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ input: {
+ flex: 1,
+ marginLeft: theme.spacing.sm,
+ color: theme.colors.text,
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.regular,
+ },
+ content: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ padding: theme.spacing.xl,
+ gap: theme.spacing.md,
+ },
+ message: {
+ fontSize: theme.fontSizes.xl,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ textAlign: "center",
+ },
+ subtext: {
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.regular,
+ color: theme.colors.muted,
+ textAlign: "center",
+ lineHeight: 24,
+ },
+});
diff --git a/app/(tabs)/videos.tsx b/app/(tabs)/videos.tsx
index c8c1b07..234139a 100644
--- a/app/(tabs)/videos.tsx
+++ b/app/(tabs)/videos.tsx
@@ -18,13 +18,23 @@ import { useAuth } from "~/lib/auth-provider";
import { vote as hiveVote } from "~/lib/hive-utils";
import { useToast } from "~/lib/toast-provider";
import { useVideoFeed, type VideoPost } from "~/lib/hooks/useQueries";
+import { ConversationDrawer } from "~/components/Feed/ConversationDrawer";
+import { useScrollLock } from "~/lib/ScrollLockContext";
import { theme } from "~/lib/theme";
+import { useAppSettings } from "~/lib/AppSettingsContext";
-const { height: SCREEN_HEIGHT, width: SCREEN_WIDTH } = Dimensions.get("window");
+import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
+
+const { height: WINDOW_HEIGHT, width: SCREEN_WIDTH } = Dimensions.get("window");
export default function VideosScreen() {
+ const { isScrollLocked } = useScrollLock();
const router = useRouter();
+ // Get tab bar height to calculate exact screen height for each video
+ const tabBarHeight = 60; // Hardcoded fallback based on _layout.tsx
+ const SCREEN_HEIGHT = WINDOW_HEIGHT - tabBarHeight;
const { session, username } = useAuth();
+ const { settings } = useAppSettings();
const { showToast } = useToast();
const { data: videos = [], isLoading } = useVideoFeed();
const [currentIndex, setCurrentIndex] = useState(0);
@@ -34,6 +44,8 @@ export default function VideosScreen() {
Record
>({});
const [playingStates, setPlayingStates] = useState>({});
+ const [selectedVideo, setSelectedVideo] = useState(null);
+ const [isCommentsVisible, setIsCommentsVisible] = useState(false);
const flatListRef = useRef(null);
// Initialize liked and vote count states when videos load
@@ -61,6 +73,24 @@ export default function VideosScreen() {
}
}).current;
+ // Prefetch thumbnails and avatars for upcoming videos (look-ahead cache)
+ useEffect(() => {
+ if (videos.length === 0) return;
+ const { Image: RNImage } = require('react-native');
+ // Prefetch assets for the next 3 videos ahead
+ for (let offset = 2; offset <= 4; offset++) {
+ const idx = currentIndex + offset;
+ if (idx < videos.length) {
+ const video = videos[idx];
+ if (video.thumbnailUrl) {
+ RNImage.prefetch(video.thumbnailUrl).catch(() => {});
+ }
+ RNImage.prefetch(`https://images.hive.blog/u/${video.username}/avatar`).catch(() => {});
+ }
+ }
+ }, [currentIndex, videos]);
+
+
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
}).current;
@@ -135,15 +165,10 @@ export default function VideosScreen() {
// Handle comment button - navigate to conversation
const handleComment = useCallback(
(video: VideoPost) => {
- router.push({
- pathname: "/conversation",
- params: {
- author: video.author,
- permlink: video.permlink,
- },
- });
+ setSelectedVideo(video);
+ setIsCommentsVisible(true);
},
- [router]
+ []
);
// Handle share button
@@ -183,7 +208,7 @@ export default function VideosScreen() {
const isVideoPlaying = playingStates[key] ?? false;
return (
-
+
{/* Thumbnail shown behind video — visible while video buffers */}
{item.thumbnailUrl && (
- {/* Left side action buttons */}
-
+ {/* Side action buttons (Regular = left, Goofy = right) */}
+
handleVote(item)}
@@ -325,6 +353,7 @@ export default function VideosScreen() {
`${item.permlink}-${index}`}
pagingEnabled
@@ -334,12 +363,12 @@ export default function VideosScreen() {
decelerationRate="fast"
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
- removeClippedSubviews
- maxToRenderPerBatch={2}
- windowSize={3}
- initialNumToRender={1}
+ removeClippedSubviews={true} // Re-enabled to help with memory/OOM crashes
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ initialNumToRender={2}
initialScrollIndex={0}
- getItemLayout={(data, index) => ({
+ getItemLayout={(_, index) => ({
length: SCREEN_HEIGHT,
offset: SCREEN_HEIGHT * index,
index,
@@ -355,6 +384,16 @@ export default function VideosScreen() {
No videos found
)}
+
+ {/* Unified Comment Drawer */}
+ {selectedVideo && (
+ setIsCommentsVisible(false)}
+ author={selectedVideo.author}
+ permlink={selectedVideo.permlink}
+ />
+ )}
);
}
@@ -371,7 +410,7 @@ const styles = StyleSheet.create({
},
videoContainer: {
width: SCREEN_WIDTH,
- height: SCREEN_HEIGHT,
+ // Note: Height is set via inline style to use the dynamic SCREEN_HEIGHT
backgroundColor: "#000",
},
thumbnail: {
@@ -499,12 +538,12 @@ const styles = StyleSheet.create({
textShadowRadius: 3,
},
// Left side action buttons
- leftActions: {
+ actionsContainer: {
position: "absolute",
- left: 16,
bottom: 200,
alignItems: "center",
gap: 20,
+ zIndex: 10,
},
actionButton: {
alignItems: "center",
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 6fdd4bb..b5cdc2d 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -10,6 +10,8 @@ import { ToastProvider } from '~/lib/toast-provider';
import { ActivityWrapper } from '~/lib/ActivityWrapper';
import { ViewportTrackerProvider } from '~/lib/ViewportTracker';
import { NotificationProvider } from '~/lib/notifications-context';
+import { ScrollLockProvider } from '~/lib/ScrollLockContext';
+import { AppSettingsProvider } from '~/lib/AppSettingsContext';
import { theme } from '~/lib/theme';
const styles = StyleSheet.create({
@@ -19,9 +21,6 @@ const styles = StyleSheet.create({
},
});
-// Keep splash screen visible while fonts are loading
-SplashScreen.preventAutoHideAsync();
-
// Initialize the query client
const queryClient = new QueryClient({
defaultOptions: {
@@ -71,9 +70,27 @@ export default function RootLayout() {
});
useEffect(() => {
- if (fontsLoaded) {
- SplashScreen.hideAsync();
- }
+ const prepare = async () => {
+ try {
+ await SplashScreen.preventAutoHideAsync();
+ } catch (error) {
+ console.warn('Splash prevent error:', error);
+ }
+ };
+ prepare();
+ }, []);
+
+ useEffect(() => {
+ const hide = async () => {
+ if (fontsLoaded) {
+ try {
+ await SplashScreen.hideAsync();
+ } catch (error) {
+ console.warn('Splash hide error:', error);
+ }
+ }
+ };
+ hide();
}, [fontsLoaded]);
if (!fontsLoaded) {
@@ -82,63 +99,46 @@ export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
\ No newline at end of file
diff --git a/app/about.tsx b/app/about.tsx
index 728154a..11a4e61 100644
--- a/app/about.tsx
+++ b/app/about.tsx
@@ -2,6 +2,7 @@ import { View, ScrollView, Pressable, StyleSheet } from 'react-native';
import { Text } from '~/components/ui/text';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
+import * as WebBrowser from 'expo-web-browser';
import { MatrixRain } from '~/components/ui/loading-effects/MatrixRain';
import { theme } from '~/lib/theme';
@@ -20,42 +21,56 @@ export default function AboutScreen() {
color={theme.colors.text}
/>
- Skatehive Community
+ Skatehive Revolution
{/* Content */}
-
-
-
-
+
-
-
-
+
-
-
-
-
+
-
-
-
+
-
-
-
+
-
-
-
+
+
+
+ WebBrowser.openBrowserAsync('https://docs.skatehive.app/docs/')}
+ style={[styles.actionButton, styles.primaryActionButton]}
+ >
+ Read the Docs
+
+ router.back()}
+ style={[styles.actionButton, styles.secondaryActionButton]}
+ >
+ Close
+
+
@@ -144,4 +159,34 @@ const styles = StyleSheet.create({
color: theme.colors.text,
fontFamily: theme.fonts.regular,
},
+ buttonContainer: {
+ marginTop: theme.spacing.xl,
+ gap: theme.spacing.md,
+ paddingBottom: theme.spacing.xxl,
+ },
+ actionButton: {
+ paddingVertical: theme.spacing.md,
+ borderRadius: theme.spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ },
+ primaryActionButton: {
+ backgroundColor: theme.colors.primary,
+ borderColor: theme.colors.primary,
+ },
+ secondaryActionButton: {
+ backgroundColor: 'transparent',
+ borderColor: 'rgba(255, 255, 255, 0.2)',
+ },
+ actionButtonText: {
+ color: theme.colors.background,
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.bold,
+ },
+ secondaryActionButtonText: {
+ color: theme.colors.text,
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.regular,
+ },
});
diff --git a/app/conversation.tsx b/app/conversation.tsx
index 1357ce3..f5edb67 100644
--- a/app/conversation.tsx
+++ b/app/conversation.tsx
@@ -120,7 +120,7 @@ export default function ConversationScreen() {
{allReplies.map((reply, index) => (
{
);
return (
-
+
);
@@ -66,21 +68,21 @@ export default function Index() {
} = useAuth();
const queryClient = useQueryClient();
- const [deletingUser, setDeletingUser] = React.useState(null);
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
const [message, setMessage] = React.useState("");
const [isFormVisible, setIsFormVisible] = React.useState(false);
- // Prefetch video feed + warm HTTP cache while user is on login screen
+ // Prefetch video feed + community feed + warm HTTP cache while user is on login screen
React.useEffect(() => {
prefetchVideoFeed(queryClient);
+ prefetchCommunityFeed(queryClient);
warmUpVideoAssets(queryClient);
}, [queryClient]);
React.useEffect(() => {
if (isAuthenticated) {
- router.push("/(tabs)/videos");
+ router.replace("/(tabs)/videos");
}
}, [isAuthenticated]);
@@ -95,16 +97,7 @@ export default function Index() {
router.push("/about");
};
- const handleDeleteUser = async (username: string) => {
- setDeletingUser(username);
- try {
- await deleteStoredUser(username);
- } catch (error) {
- console.error("Error deleting user:", error);
- } finally {
- setDeletingUser(null);
- }
- };
+
const handleSpectator = async () => {
try {
@@ -123,20 +116,26 @@ export default function Index() {
return;
}
await login(username, password, method, pin);
+ // Prefetch user data after successful login
+ prefetchProfile(queryClient, username);
+ prefetchBalance(queryClient, username);
router.replace("/(tabs)/videos");
- } catch (error: any) {
- if (
- error instanceof InvalidKeyFormatError ||
- error instanceof AccountNotFoundError ||
- error instanceof InvalidKeyError ||
- error instanceof AuthError ||
- error instanceof HiveError
- ) {
- setMessage(error.message);
- } else {
- setMessage("An unexpected error occurred");
+ } catch (error: any) {
+ if (
+ error instanceof InvalidKeyFormatError ||
+ error instanceof AccountNotFoundError ||
+ error instanceof InvalidKeyError ||
+ error instanceof AuthError ||
+ error instanceof HiveError
+ ) {
+ // Suppress biometric failure messages as requested
+ if (!error.message.includes('Biometric authentication')) {
+ setMessage(error.message);
+ }
+ } else {
+ setMessage("An unexpected error occurred");
+ }
}
- }
};
const handleQuickLogin = async (
@@ -146,6 +145,9 @@ export default function Index() {
) => {
try {
await loginStoredUser(selectedUsername, pin);
+ // Prefetch user data after successful quick login
+ prefetchProfile(queryClient, selectedUsername);
+ prefetchBalance(queryClient, selectedUsername);
router.replace("/(tabs)/videos");
} catch (error) {
if (
@@ -155,7 +157,11 @@ export default function Index() {
error instanceof AuthError ||
error instanceof HiveError
) {
- setMessage((error as Error).message);
+ // Suppress biometric failure messages as requested
+ const msg = (error as Error).message;
+ if (!msg.includes('Biometric authentication')) {
+ setMessage(msg);
+ }
} else {
setMessage("Error with quick login");
}
@@ -174,7 +180,7 @@ export default function Index() {
-
+
@@ -241,16 +247,17 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
+ zIndex: 0,
},
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: "#000000",
},
infoButton: {
position: "absolute",
top: 48,
right: 24,
- zIndex: 10,
+ zIndex: 999,
},
infoButtonContent: {
backgroundColor: "rgba(255, 255, 255, 0.2)",
@@ -264,6 +271,7 @@ const styles = StyleSheet.create({
bottom: 0,
height: "60%",
flexDirection: "column",
+ zIndex: 1,
},
fadeBand: {
flex: 1,
@@ -271,6 +279,7 @@ const styles = StyleSheet.create({
},
formWrapper: {
flex: 1,
+ zIndex: 2,
},
scrollContent: {
flexGrow: 1,
diff --git a/babel.config.js b/babel.config.js
index e4c6743..34ca9b3 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -5,7 +5,7 @@ module.exports = function (api) {
plugins: [
['dotenv-import', {
moduleName: '@env',
- path: '.env',
+ path: '.env.local',
blacklist: null,
whitelist: null,
safe: false,
diff --git a/components/Feed/Conversation.tsx b/components/Feed/Conversation.tsx
index 41db11e..65c801d 100644
--- a/components/Feed/Conversation.tsx
+++ b/components/Feed/Conversation.tsx
@@ -76,7 +76,7 @@ export function Conversation({ discussion, onClose }: ConversationProps) {
{allReplies.map((reply, index) => (
{/* Add separator between replies except for the last one */}
diff --git a/components/Feed/ConversationDrawer.tsx b/components/Feed/ConversationDrawer.tsx
index 783406a..45cd4bc 100644
--- a/components/Feed/ConversationDrawer.tsx
+++ b/components/Feed/ConversationDrawer.tsx
@@ -1,473 +1,255 @@
-import React, { useState, useRef, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import {
View,
- Modal,
- ScrollView,
- Pressable,
StyleSheet,
- ActivityIndicator,
+ Modal,
Animated,
+ PanResponder,
Dimensions,
- TextInput,
- Keyboard,
+ Pressable,
+ ActivityIndicator,
+ ScrollView,
KeyboardAvoidingView,
Platform,
} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { FontAwesome, Ionicons } from '@expo/vector-icons';
-import * as ImagePicker from 'expo-image-picker';
-import { Text } from '~/components/ui/text';
+import { Text } from '../ui/text';
import { PostCard } from './PostCard';
+import { ConversationReply } from './ConversationReply';
+import { ReplyComposer } from '../ui/ReplyComposer';
+import { useReplies } from '~/lib/hooks/useReplies';
import { useAuth } from '~/lib/auth-provider';
-import { useToast } from '~/lib/toast-provider';
-import { createHiveComment } from '~/lib/upload/post-utils';
-import { uploadVideoToWorker, createVideoIframe } from '~/lib/upload/video-upload';
-import { uploadImageToHive, createImageMarkdown } from '~/lib/upload/image-upload';
+import { getContent } from '~/lib/hive-utils';
+import { useScrollLock } from '~/lib/ScrollLockContext';
import { theme } from '~/lib/theme';
-import type { NestedDiscussion } from '~/lib/types';
import type { Discussion } from '@hiveio/dhive';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
-const DRAWER_HEIGHT = SCREEN_HEIGHT * 0.95;
interface ConversationDrawerProps {
- visible: boolean;
+ isVisible: boolean;
onClose: () => void;
- discussion: Discussion;
+ post?: Discussion;
+ author?: string;
+ permlink?: string;
}
-export function ConversationDrawer({ visible, onClose, discussion }: ConversationDrawerProps) {
- const { username, session } = useAuth();
- const { showToast } = useToast();
+export function ConversationDrawer({
+ isVisible,
+ onClose,
+ post: initialPost,
+ author: initialAuthor,
+ permlink: initialPermlink,
+}: ConversationDrawerProps) {
const insets = useSafeAreaInsets();
- // Remove the useReplies hook since we're not showing comments anymore
- // const { comments, isLoading, error } = useReplies(
- // discussion.author,
- // discussion.permlink,
- // true
- // );
-
- const [optimisticReplies, setOptimisticReplies] = useState([]);
- const [isReplyExpanded, setIsReplyExpanded] = useState(true); // Changed to true to show expanded by default
- const [replyContent, setReplyContent] = useState('');
- const [media, setMedia] = useState(null);
- const [mediaType, setMediaType] = useState<'image' | 'video' | null>(null);
- const [isUploading, setIsUploading] = useState(false);
- const [uploadProgress, setUploadProgress] = useState('');
- const scrollViewRef = useRef(null);
-
- const handleExpandReply = () => {
- setIsReplyExpanded(true);
- // Scroll to bottom after a brief delay to ensure the expanded box is rendered
- setTimeout(() => {
- scrollViewRef.current?.scrollToEnd({ animated: true });
- }, 100);
- };
+ const { username } = useAuth();
+ const { isScrollLocked } = useScrollLock();
+ const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
+
+ const [post, setPost] = useState(initialPost);
+ const [isPostLoading, setIsPostLoading] = useState(false);
+ const [scrollOffset, setScrollOffset] = useState(0);
+
+ const author = post?.author || initialAuthor || '';
+ const permlink = post?.permlink || initialPermlink || '';
+
+ const { comments, isLoading: isCommentsLoading, error, refetch } = useReplies(
+ author,
+ permlink,
+ isVisible && !!author && !!permlink
+ );
- const handleReplySuccess = (newReply: Discussion) => {
- // Convert Discussion to NestedDiscussion
- const nestedReply: NestedDiscussion = {
- ...newReply,
- replies: [],
- depth: 0,
- };
- setOptimisticReplies((prev) => [...prev, nestedReply]);
- };
+ useEffect(() => {
+ if (isVisible && !post && initialAuthor && initialPermlink) {
+ const fetchPost = async () => {
+ setIsPostLoading(true);
+ const fetchedPost = await getContent(initialAuthor, initialPermlink);
+ if (fetchedPost) {
+ setPost(fetchedPost);
+ }
+ setIsPostLoading(false);
+ };
+ fetchPost();
+ }
+ }, [isVisible, initialAuthor, initialPermlink, post]);
- const translateY = useRef(new Animated.Value(DRAWER_HEIGHT)).current;
- const backdropOpacity = useRef(new Animated.Value(0)).current;
+ const [optimisticReplies, setOptimisticReplies] = useState([]);
useEffect(() => {
- if (visible) {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 1,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start();
+ if (isVisible) {
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ tension: 50,
+ friction: 10,
+ }).start();
+ setOptimisticReplies([]); // Reset optimistic replies when drawer opens
} else {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: DRAWER_HEIGHT,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start();
+ Animated.timing(translateY, {
+ toValue: SCREEN_HEIGHT,
+ duration: 250,
+ useNativeDriver: true,
+ }).start();
}
- }, [visible]);
+ }, [isVisible]);
const handleClose = () => {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: DRAWER_HEIGHT,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start(() => {
+ Animated.timing(translateY, {
+ toValue: SCREEN_HEIGHT,
+ duration: 200,
+ useNativeDriver: true,
+ }).start(() => {
onClose();
- setIsReplyExpanded(false);
- setReplyContent('');
- setMedia(null);
- setMediaType(null);
- Keyboard.dismiss();
});
};
- const pickMedia = async () => {
- try {
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: ['images', 'videos'],
- allowsEditing: false,
- quality: 0.75,
- exif: false,
- });
-
- if (!result.canceled && result.assets?.[0]) {
- const asset = result.assets[0];
- setMedia(asset.uri);
- setMediaType(asset.type === 'video' ? 'video' : 'image');
- }
- } catch (error) {
- showToast('Failed to select media', 'error');
- }
- };
-
- const removeMedia = () => {
- setMedia(null);
- setMediaType(null);
- };
-
- const handleReply = async () => {
- if (!replyContent.trim() && !media) {
- showToast('Please add some content to your reply', 'error');
- return;
- }
-
- if (!username || username === 'SPECTATOR' || !session?.decryptedKey) {
- showToast('Please log in to reply', 'error');
- return;
- }
-
- setIsUploading(true);
- setUploadProgress('');
-
- try {
- let replyBody = replyContent;
-
- // Handle media upload
- if (media && mediaType) {
- const fileName = media.split('/').pop() || `${Date.now()}.${mediaType === 'image' ? 'jpg' : 'mp4'}`;
-
- if (mediaType === 'image') {
- setUploadProgress('Uploading image...');
- const imageResult = await uploadImageToHive(
- media,
- fileName,
- 'image/jpeg',
- {
- username,
- privateKey: session.decryptedKey,
- }
- );
- const imageMarkdown = createImageMarkdown(imageResult.url, 'Uploaded image');
- replyBody += replyBody ? `\n\n${imageMarkdown}` : imageMarkdown;
- } else if (mediaType === 'video') {
- const fileName = media.split('/').pop() || `${Date.now()}.mp4`;
-
- setUploadProgress('Uploading video...');
-
- const videoResult = await uploadVideoToWorker(
- media,
- fileName,
- 'video/mp4',
- {
- creator: username,
- }
- );
-
- const videoIframe = createVideoIframe(videoResult.gatewayUrl, 'Video');
- replyBody += replyBody ? `\n\n${videoIframe}` : videoIframe;
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: (evt, gestureState) => {
+ // Immediately start if touching the top part (header/handle)
+ return evt.nativeEvent.locationY < 100;
+ },
+ onMoveShouldSetPanResponder: () => false,
+ onMoveShouldSetPanResponderCapture: (_, gestureState) => {
+ // Capture the gesture if swiping down at the top of the scroll
+ return gestureState.dy > 10 && scrollOffset <= 0;
+ },
+ onPanResponderMove: (_, gestureState) => {
+ if (gestureState.dy > 0) {
+ translateY.setValue(gestureState.dy);
}
- }
-
- setUploadProgress('Posting reply...');
-
- await createHiveComment(
- replyBody,
- discussion.author,
- discussion.permlink,
- {
- username,
- privateKey: session.decryptedKey,
+ },
+ onPanResponderRelease: (_, gestureState) => {
+ // Detect fast flicks down (positive velocity) or significant distance
+ if (gestureState.dy > 150 || gestureState.vy > 0.5) {
+ handleClose();
+ } else {
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ tension: 65,
+ friction: 11,
+ }).start();
}
- );
-
- // Create optimistic reply
- const newReply = {
- author: username,
- permlink: `reply-${Date.now()}`,
- body: replyBody,
- created: new Date().toISOString(),
- parent_author: discussion.author,
- parent_permlink: discussion.permlink,
- children: 0,
- active_votes: [],
- pending_payout_value: '0.000 HBD',
- total_payout_value: '0.000 HBD',
- total_pending_payout_value: '0.000 HBD',
- curator_payout_value: '0.000 HBD',
- root_comment: 0,
- id: Date.now(),
- category: '',
- title: '',
- json_metadata: '{}',
- last_update: new Date().toISOString(),
- active: new Date().toISOString(),
- last_payout: '1970-01-01T00:00:00',
- depth: 0,
- net_rshares: '0',
- abs_rshares: '0',
- vote_rshares: '0',
- children_abs_rshares: '0',
- cashout_time: '1969-12-31T23:59:59',
- max_cashout_time: '1969-12-31T23:59:59',
- total_vote_weight: '0',
- reward_weight: 10000,
- author_rewards: '0',
- net_votes: 0,
- max_accepted_payout: '1000000.000 HBD',
- percent_hbd: 10000,
- allow_replies: true,
- allow_votes: true,
- allow_curation_rewards: true,
- beneficiaries: [],
- url: `/@${username}/reply-${Date.now()}`,
- root_title: '',
- replies: [],
- author_reputation: 0,
- promoted: '0.000 HBD',
- body_length: replyBody.length,
- reblogged_by: [],
- blacklists: [],
- } as unknown as Discussion;
-
- // Convert to NestedDiscussion for optimistic updates
- const nestedNewReply: NestedDiscussion = {
- ...newReply,
- replies: [],
- depth: 0,
- };
+ },
+ })
+ ).current;
- setOptimisticReplies(prev => [...prev, nestedNewReply]);
-
- // Clear form
- setReplyContent('');
- setMedia(null);
- setMediaType(null);
- setIsReplyExpanded(false);
-
- showToast('Reply posted successfully!', 'success');
- Keyboard.dismiss();
-
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : 'An error occurred';
- showToast(errorMsg, 'error');
- } finally {
- setIsUploading(false);
- setUploadProgress('');
- }
+ const handleReplySuccess = (newReply: Discussion) => {
+ setOptimisticReplies((prev) => [newReply, ...prev]);
};
- const renderSmallReplyBox = () => (
-
-
-
-
- {username ? username[0].toUpperCase() : 'U'}
-
-
- Add a comment...
-
-
-
-
-
-
-
-
-
-
- );
-
- const renderExpandedReplyBox = () => (
-
- {/* Header */}
-
- Add a comment
- setIsReplyExpanded(false)}
- style={styles.collapseButton}
- >
-
-
-
-
- {/* Upload Progress */}
- {uploadProgress ? (
-
- {uploadProgress}
-
- ) : null}
-
- {/* Media Preview */}
- {media && (
-
- {mediaType === 'image' ? (
-
- Image attached
-
- ) : (
-
- Video attached
-
- )}
-
-
-
-
- )}
-
- {/* Text Input */}
-
-
- {/* Actions */}
-
-
-
- Add media
-
-
-
- {isUploading ? (
-
- ) : (
- Post
- )}
-
-
-
- );
-
- if (!visible) return null;
+ const allReplies = [...optimisticReplies, ...comments];
return (
-
- {/* Backdrop */}
-
-
-
-
- {/* Drawer */}
+
+
+
-
- {/* Handle */}
-
+ {/* Gesture Sensitive Header Area */}
+
+ {/* Handle bar for swiping */}
+
+
+
- {/* Header */}
-
-
-
-
- Reply
-
+ {/* Sticky Header: Original Post Context */}
+
+
+ Conversation
+
+
+
+
+
+ {/* Minimal post preview for context */}
+
+ {isPostLoading ? (
+
+
+
+ ) : post ? (
+
+ ) : null}
+
+
- {/* Content - Just the reply box, no comments shown */}
+
setScrollOffset(e.nativeEvent.contentOffset.y)}
+ scrollEventThrottle={16}
>
- {/* Show the post being replied to */}
-
-
+
+
+ {post?.children || 0} Comments
+
+ {(isCommentsLoading || isPostLoading) && (
+
+ )}
+
+ {error ? (
+
+ Error: {error}
+
+ Retry
+
+
+ ) : (allReplies || []).length === 0 && !(isCommentsLoading || isPostLoading) ? (
+
+ No comments yet. Be the first!
+
+ ) : (
+ (allReplies || []).map((reply, index) => (
+
+
+
+ ))
+ )}
- {/* Reply Section */}
- {username && username !== 'SPECTATOR' ? (
-
- {isReplyExpanded ? renderExpandedReplyBox() : renderSmallReplyBox()}
-
- ) : (
-
- Please log in to comment
+ {/* Bottom composer always visible */}
+ {post && (
+
+
)}
@@ -478,273 +260,128 @@ export function ConversationDrawer({ visible, onClose, discussion }: Conversatio
}
const styles = StyleSheet.create({
- modalContainer: {
+ overlay: {
flex: 1,
+ justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
- backgroundColor: 'rgba(0, 0, 0, 0.7)',
- },
- backdropPress: {
- flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.7)',
},
drawer: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- height: DRAWER_HEIGHT,
backgroundColor: theme.colors.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
- paddingTop: theme.spacing.xs,
+ height: SCREEN_HEIGHT * 0.9,
+ width: '100%',
+ overflow: 'hidden',
},
- keyboardAvoidingView: {
- flex: 1,
+ postLoadingContainer: {
+ height: 100,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ handleBarContainer: {
+ width: '100%',
+ height: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: theme.colors.background,
},
- handle: {
+ handleBar: {
width: 40,
- height: 4,
+ height: 5,
+ borderRadius: 3,
backgroundColor: theme.colors.border,
- borderRadius: 2,
- alignSelf: 'center',
- marginBottom: theme.spacing.sm,
},
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingHorizontal: theme.spacing.md,
- paddingBottom: theme.spacing.sm,
+ stickyHeader: {
+ backgroundColor: theme.colors.background,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
- backButton: {
- padding: theme.spacing.xs,
- width: 40,
+ headerInfo: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingBottom: 8,
},
headerTitle: {
- fontSize: theme.fontSizes.lg,
+ fontSize: 18,
fontWeight: 'bold',
color: theme.colors.text,
- fontFamily: theme.fonts.bold,
- flex: 1,
- textAlign: 'center',
- },
- headerSpacer: {
- width: 40,
- },
- content: {
- flex: 1,
- padding: theme.spacing.md,
- },
- replyContainer: {
- marginBottom: theme.spacing.sm,
- },
- replySeparator: {
- height: 1,
- backgroundColor: theme.colors.border,
- marginVertical: theme.spacing.xs,
- marginLeft: 54,
},
- loadingContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
+ closeButton: {
+ padding: 4,
},
- loadingText: {
- marginTop: theme.spacing.sm,
- color: theme.colors.muted,
- fontSize: theme.fontSizes.sm,
+ postContext: {
+ maxHeight: 200, // Limit height of the sticky post context
+ overflow: 'hidden',
+ paddingHorizontal: 8,
},
- errorContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
- },
- errorText: {
- color: theme.colors.danger,
- fontSize: theme.fontSizes.sm,
- },
- emptyContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
- },
- emptyText: {
- color: theme.colors.muted,
- fontSize: theme.fontSizes.sm,
- textAlign: 'center',
- },
- replySection: {
- borderTopWidth: 1,
- borderTopColor: theme.colors.border,
- backgroundColor: theme.colors.card,
- },
- smallReplyBox: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: theme.spacing.sm,
- backgroundColor: theme.colors.card,
- minHeight: 56, // Ensure minimum touch target height
- },
- smallReplyContent: {
- flexDirection: 'row',
- alignItems: 'center',
+ container: {
flex: 1,
- paddingVertical: theme.spacing.xs,
},
- profilePicSmall: {
- width: 32,
- height: 32,
- borderRadius: 16,
- backgroundColor: theme.colors.green,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: theme.spacing.sm,
- },
- profileInitial: {
- color: theme.colors.background,
- fontSize: theme.fontSizes.sm,
- fontWeight: 'bold',
+ repliesList: {
+ flex: 1,
},
- smallReplyPlaceholder: {
- color: theme.colors.gray,
- fontSize: theme.fontSizes.md,
+ scrollContent: {
+ flexGrow: 1, // Ensure it fills space for gestures
+ paddingBottom: 100, // Space for composer
},
- smallReplyIcons: {
+ repliesHeader: {
flexDirection: 'row',
- gap: theme.spacing.md,
- },
- smallIconButton: {
- padding: theme.spacing.xs,
- minWidth: 32,
- minHeight: 32,
alignItems: 'center',
- justifyContent: 'center',
- },
- expandedReplyBox: {
+ gap: 12,
+ padding: 16,
backgroundColor: theme.colors.card,
- paddingHorizontal: theme.spacing.md,
- paddingTop: theme.spacing.md,
- paddingBottom: theme.spacing.md, // Increased bottom padding
- },
- expandedReplyHeader: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: theme.spacing.sm,
},
- expandedReplyTitle: {
- fontSize: theme.fontSizes.md,
- fontWeight: 'bold',
- color: theme.colors.text,
- fontFamily: theme.fonts.bold,
- },
- collapseButton: {
- padding: theme.spacing.xs,
- },
- progressContainer: {
- marginBottom: theme.spacing.sm,
- },
- progressText: {
- color: theme.colors.green,
- fontSize: theme.fontSizes.sm,
- textAlign: 'center',
- },
- mediaPreview: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: theme.spacing.sm,
- backgroundColor: theme.colors.background,
- borderRadius: theme.borderRadius.md,
- marginBottom: theme.spacing.sm,
- },
- mediaImageContainer: {
- flex: 1,
- },
- mediaLabel: {
- color: theme.colors.text,
- fontSize: theme.fontSizes.sm,
+ repliesTitle: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: theme.colors.muted,
},
- removeMediaButton: {
- padding: theme.spacing.xs,
+ replyWrapper: {
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
},
- expandedTextInput: {
- color: theme.colors.text,
- fontSize: theme.fontSizes.md,
- fontFamily: theme.fonts.default,
- minHeight: 100,
- maxHeight: 120,
- textAlignVertical: 'top',
- borderWidth: 1,
- borderColor: theme.colors.border,
- borderRadius: theme.borderRadius.md,
- padding: theme.spacing.sm,
- marginBottom: theme.spacing.sm,
+ composerWrapper: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ borderTopWidth: 1,
+ borderTopColor: theme.colors.border,
backgroundColor: theme.colors.background,
},
- expandedActions: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingBottom: theme.spacing.lg, // Increased bottom padding for buttons
- marginBottom: theme.spacing.sm, // Add margin for extra space
- },
- mediaActionButton: {
- flexDirection: 'row',
+ errorContainer: {
+ padding: 32,
alignItems: 'center',
- gap: theme.spacing.xs,
- padding: theme.spacing.sm,
- borderRadius: theme.borderRadius.md,
- backgroundColor: theme.colors.background,
- borderWidth: 1,
- borderColor: theme.colors.border,
- minHeight: 44,
},
- mediaButtonText: {
- color: theme.colors.green,
- fontSize: theme.fontSizes.sm,
- fontFamily: theme.fonts.default,
- },
- postButton: {
- backgroundColor: theme.colors.green,
- paddingHorizontal: theme.spacing.lg,
- paddingVertical: theme.spacing.sm,
- borderRadius: theme.borderRadius.md,
- minWidth: 80,
- alignItems: 'center',
+ errorText: {
+ color: theme.colors.danger,
+ textAlign: 'center',
+ marginBottom: 16,
},
- postButtonDisabled: {
- backgroundColor: theme.colors.gray,
- opacity: 0.6,
+ retryButton: {
+ padding: 12,
+ backgroundColor: theme.colors.primary,
+ borderRadius: 8,
},
- postButtonText: {
- color: theme.colors.background,
- fontSize: theme.fontSizes.sm,
+ retryText: {
+ color: '#000',
fontWeight: 'bold',
- fontFamily: theme.fonts.bold,
},
- loginPrompt: {
- padding: theme.spacing.md,
- alignItems: 'center',
- backgroundColor: theme.colors.card,
- borderTopWidth: 1,
- borderTopColor: theme.colors.border,
- },
- loginPromptText: {
- color: theme.colors.muted,
- fontSize: theme.fontSizes.sm,
- },
- placeholderContainer: {
+ emptyContainer: {
+ flex: 1,
+ padding: 48,
alignItems: 'center',
- padding: theme.spacing.xl,
+ justifyContent: 'center',
+ minHeight: 200,
},
- placeholderText: {
+ emptyText: {
color: theme.colors.muted,
- fontSize: theme.fontSizes.md,
+ fontSize: 16,
textAlign: 'center',
},
- postPreview: {
- backgroundColor: theme.colors.card,
- },
});
diff --git a/components/Feed/Feed.tsx b/components/Feed/Feed.tsx
index b151b11..9084ff2 100644
--- a/components/Feed/Feed.tsx
+++ b/components/Feed/Feed.tsx
@@ -8,10 +8,12 @@ import {
Pressable,
} from "react-native";
import { useRouter } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
import { Text } from "../ui/text";
import { PostCard } from "./PostCard";
import { ActivityIndicator } from "react-native";
import { useAuth } from "~/lib/auth-provider";
+import { useFeedFilter } from "~/lib/FeedFilterContext";
import { useSnaps } from "~/lib/hooks/useSnaps";
import { theme } from "~/lib/theme";
import {
@@ -20,6 +22,8 @@ import {
} from "~/lib/ViewportTracker";
import { BadgedIcon } from "../ui/BadgedIcon";
import { useNotificationContext } from "~/lib/notifications-context";
+import { useScrollLock } from "~/lib/ScrollLockContext";
+import { ConversationDrawer } from "./ConversationDrawer";
import type { Discussion } from "@hiveio/dhive";
interface FeedProps {
@@ -28,13 +32,26 @@ interface FeedProps {
}
function FeedContent({ refreshTrigger, onRefresh }: FeedProps) {
+ const { filter } = useFeedFilter();
+ const { isScrollLocked } = useScrollLock();
const router = useRouter();
const { username, mutedList, blacklistedList } = useAuth();
- const { comments, isLoading, loadNextPage, hasMore, refresh } = useSnaps();
+ const { comments, isLoading, loadNextPage, hasMore, refresh } = useSnaps(filter, username);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const { updateVisibleItems } = useViewportTracker();
const { badgeCount } = useNotificationContext();
+ // Conversation drawer state (lifted out of PostCard)
+ const [conversationPost, setConversationPost] = React.useState(null);
+
+ const handleOpenConversation = React.useCallback((post: Discussion) => {
+ setConversationPost(post);
+ }, []);
+
+ const handleCloseConversation = React.useCallback(() => {
+ setConversationPost(null);
+ }, []);
+
// Handle pull-to-refresh
const handleRefresh = React.useCallback(async () => {
setIsRefreshing(true);
@@ -77,10 +94,14 @@ function FeedContent({ refreshTrigger, onRefresh }: FeedProps) {
// Don't filter out the user's own posts
if (post.author === username) return true;
+ const authorLower = post.author.toLowerCase();
+ const mutedLowerList = mutedList.map((u) => u.toLowerCase());
+ const blacklistedLowerList = blacklistedList.map((u) => u.toLowerCase());
+
// Filter out muted and blacklisted users
return (
- !mutedList.includes(post.author) &&
- !blacklistedList.includes(post.author)
+ !mutedLowerList.includes(authorLower) &&
+ !blacklistedLowerList.includes(authorLower)
);
});
}, [feedData, mutedList, blacklistedList, username]);
@@ -91,9 +112,10 @@ function FeedContent({ refreshTrigger, onRefresh }: FeedProps) {
key={item.permlink}
post={item}
currentUsername={username || ""}
+ onOpenConversation={handleOpenConversation}
/>
),
- [username]
+ [username, handleOpenConversation]
);
const keyExtractor = React.useCallback(
@@ -106,33 +128,9 @@ function FeedContent({ refreshTrigger, onRefresh }: FeedProps) {
[]
);
- const handleNotificationsPress = React.useCallback(() => {
- router.push("/(tabs)/notifications");
- }, [router]);
-
const ListHeaderComponent = React.useCallback(
- () => (
-
- Feed
- 0
- ? `Notifications, ${badgeCount} unread`
- : "Notifications"
- }
- >
-
-
-
- ),
- [handleNotificationsPress, badgeCount]
+ () => ,
+ []
);
const ListFooterComponent = isLoading ? (
@@ -146,6 +144,7 @@ function FeedContent({ refreshTrigger, onRefresh }: FeedProps) {
}
- removeClippedSubviews={true}
+ removeClippedSubviews={true} // Re-enabled to help with memory/OOM crashes
initialNumToRender={5}
- maxToRenderPerBatch={3}
- windowSize={7}
+ maxToRenderPerBatch={5}
+ windowSize={11}
updateCellsBatchingPeriod={50}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
/>
+
+ {/* Single shared conversation drawer */}
+ {conversationPost && (
+
+ )}
);
}
export function Feed({ refreshTrigger, onRefresh }: FeedProps) {
return (
-
-
-
+
);
}
@@ -189,23 +195,13 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
- header: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: theme.spacing.sm,
- paddingHorizontal: theme.spacing.md,
- paddingTop: theme.spacing.xxs,
- },
- headerText: {
- fontSize: theme.fontSizes.xxl,
- fontWeight: "bold",
- color: theme.colors.text,
- lineHeight: 40,
- fontFamily: theme.fonts.bold,
+ dropdownTrigger: {
+ flexDirection: 'row',
+ alignItems: 'center',
},
- notificationButton: {
- padding: theme.spacing.xs,
+ chevron: {
+ marginLeft: theme.spacing.xs,
+ marginTop: 4,
},
separator: {
height: 1,
diff --git a/components/Feed/FullConversationDrawer.tsx b/components/Feed/FullConversationDrawer.tsx
deleted file mode 100644
index ec3e031..0000000
--- a/components/Feed/FullConversationDrawer.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import {
- View,
- Modal,
- ScrollView,
- Pressable,
- StyleSheet,
- ActivityIndicator,
- Animated,
- Dimensions,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { Ionicons } from '@expo/vector-icons';
-import { Text } from '~/components/ui/text';
-import { PostCard } from './PostCard';
-import { ReplyComposer } from '~/components/ui/ReplyComposer';
-import { useReplies } from '~/lib/hooks/useReplies';
-import { useAuth } from '~/lib/auth-provider';
-import { theme } from '~/lib/theme';
-import type { Discussion } from '@hiveio/dhive';
-import type { NestedDiscussion } from '~/lib/types';
-
-const { height: SCREEN_HEIGHT } = Dimensions.get('window');
-
-interface FullConversationDrawerProps {
- visible: boolean;
- onClose: () => void;
- discussion: Discussion;
-}
-
-export function FullConversationDrawer({ visible, onClose, discussion }: FullConversationDrawerProps) {
- const { username } = useAuth();
- const insets = useSafeAreaInsets();
- const { comments, isLoading, error } = useReplies(
- discussion.author,
- discussion.permlink,
- visible // Only fetch when visible
- );
-
- const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
- const backdropOpacity = useRef(new Animated.Value(0)).current;
-
- useEffect(() => {
- if (visible) {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 1,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start();
- } else {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: SCREEN_HEIGHT,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start();
- }
- }, [visible]);
-
- const handleClose = () => {
- Animated.parallel([
- Animated.timing(translateY, {
- toValue: SCREEN_HEIGHT,
- duration: 300,
- useNativeDriver: true,
- }),
- Animated.timing(backdropOpacity, {
- toValue: 0,
- duration: 300,
- useNativeDriver: true,
- }),
- ]).start(() => {
- onClose();
- });
- };
-
- // Recursive function to render all nested replies as PostCards with only 1 level of indentation
- const renderNestedReplies = (replies: NestedDiscussion[], depth: number = 0): React.ReactElement[] => {
- return replies.map((reply, index) => (
-
- {/* Add indentation only for depth > 0 (replies to replies) */}
- 0 ? styles.indentedReply : undefined}>
- {/* Each reply is a full PostCard */}
-
-
-
- {/* Recursively render nested replies - they'll all get the same indentation */}
- {reply.replies && reply.replies.length > 0 && (
-
- {renderNestedReplies(reply.replies, depth + 1)}
-
- )}
-
- {/* Separator between same-level replies */}
- {index < replies.length - 1 && (
-
- )}
-
- ));
- };
-
- if (!visible) return null;
-
- return (
-
-
- {/* Backdrop */}
-
-
-
-
- {/* Full Screen Drawer */}
-
-
- {/* Header with safe area padding */}
-
-
-
-
-
- Conversation
-
-
-
-
- {/* Content */}
-
- {/* Original Post */}
-
-
-
-
-
-
- {/* All Replies (Recursive) */}
- {isLoading ? (
-
-
- Loading conversation...
-
- ) : error ? (
-
- Error loading conversation
-
- ) : comments.length === 0 ? (
-
- No comments yet
-
- ) : (
-
- {renderNestedReplies(comments)}
-
- )}
-
-
- {/* Reply Box at Bottom */}
- {username && username !== 'SPECTATOR' && (
-
- {}}
- placeholder="Write your reply..."
- buttonLabel="POST"
- />
-
- )}
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- modalContainer: {
- flex: 1,
- },
- backdrop: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
- },
- backdropPress: {
- flex: 1,
- },
- drawer: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- height: SCREEN_HEIGHT,
- backgroundColor: theme.colors.background,
- },
- keyboardAvoidingView: {
- flex: 1,
- },
- headerContainer: {
- backgroundColor: theme.colors.background,
- },
- handle: {
- width: 40,
- height: 4,
- backgroundColor: theme.colors.border,
- borderRadius: 2,
- alignSelf: 'center',
- marginBottom: theme.spacing.sm,
- },
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingHorizontal: theme.spacing.md,
- paddingBottom: theme.spacing.sm,
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.border,
- },
- backButton: {
- padding: theme.spacing.xs,
- width: 40,
- },
- headerTitle: {
- fontSize: theme.fontSizes.lg,
- fontWeight: 'bold',
- color: theme.colors.text,
- fontFamily: theme.fonts.bold,
- flex: 1,
- textAlign: 'center',
- },
- headerSpacer: {
- width: 40,
- },
- content: {
- flex: 1,
- },
- mainPost: {
- backgroundColor: theme.colors.card,
- },
- divider: {
- height: 8,
- backgroundColor: theme.colors.background,
- },
- repliesContainer: {
- backgroundColor: theme.colors.card,
- },
- nestedContainer: {
- backgroundColor: theme.colors.card,
- },
- separator: {
- height: 1,
- backgroundColor: theme.colors.border,
- marginBottom: theme.spacing.sm,
- },
- loadingContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
- },
- loadingText: {
- marginTop: theme.spacing.sm,
- color: theme.colors.muted,
- fontSize: theme.fontSizes.sm,
- },
- errorContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
- },
- errorText: {
- color: theme.colors.danger,
- fontSize: theme.fontSizes.sm,
- },
- emptyContainer: {
- alignItems: 'center',
- padding: theme.spacing.xl,
- },
- emptyText: {
- color: theme.colors.muted,
- fontSize: theme.fontSizes.sm,
- textAlign: 'center',
- },
- safeArea: {
- flex: 1,
- },
- indentedReply: {
- marginLeft: theme.spacing.md,
- },
- replySection: {
- borderTopWidth: 1,
- borderTopColor: theme.colors.border,
- backgroundColor: theme.colors.card,
- },
-});
diff --git a/components/Feed/MediaPreview.tsx b/components/Feed/MediaPreview.tsx
index b229c1b..fb938c4 100644
--- a/components/Feed/MediaPreview.tsx
+++ b/components/Feed/MediaPreview.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
-import { Image, Modal, Pressable, View, Dimensions, StyleSheet } from 'react-native';
+import { Modal, Pressable, View, Dimensions, StyleSheet } from 'react-native';
+import { Image } from 'expo-image';
import { VideoPlayer } from './VideoPlayer';
import { VideoWithAutoplay } from './VideoWithAutoplay';
import { EmbedPlayer } from './EmbedPlayer';
@@ -102,9 +103,10 @@ export function MediaPreview({
{
- const { width, height } = e.nativeEvent.source;
+ const { width, height } = e.source;
handleImageLoad(index, width, height);
}}
/>
@@ -129,7 +131,8 @@ export function MediaPreview({
) : selectedMedia?.type === 'video' ? (
diff --git a/components/Feed/PostCard.tsx b/components/Feed/PostCard.tsx
index 2008dab..11802af 100644
--- a/components/Feed/PostCard.tsx
+++ b/components/Feed/PostCard.tsx
@@ -1,27 +1,26 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
-import { FontAwesome } from '@expo/vector-icons';
+import { FontAwesome, Ionicons } from '@expo/vector-icons';
// import * as SecureStore from 'expo-secure-store';
import * as Haptics from 'expo-haptics';
-import { Image, Pressable, View, Linking, ActivityIndicator, StyleSheet, Modal, TextInput, ScrollView } from 'react-native';
+import { Pressable, View, Linking, ActivityIndicator, StyleSheet, Modal, TextInput, ScrollView } from 'react-native';
+import { Image } from 'expo-image';
import { router } from 'expo-router';
// import { API_BASE_URL } from '~/lib/constants';
import { vote as hiveVote, submitEncryptedReport } from '~/lib/hive-utils';
import { useAuth } from '~/lib/auth-provider';
+import { useScrollLock } from '~/lib/ScrollLockContext';
import { useVoteValue } from '~/lib/hooks/useVoteValue';
import { useViewportTracker } from '~/lib/ViewportTracker';
import { Text } from '../ui/text';
import { VotingSlider } from '../ui/VotingSlider';
+import { VotePresetButtons } from '../ui/VotePresetButtons';
+import { useAppSettings } from '~/lib/AppSettingsContext';
import { MediaPreview } from './MediaPreview';
+import { CommentBottomSheet } from '../ui/CommentBottomSheet';
import { EnhancedMarkdownRenderer } from '../markdown/EnhancedMarkdownRenderer';
-// Lazy imports break the require cycle:
-// PostCard → ConversationDrawer → PostCard
-// PostCard → FullConversationDrawer → PostCard
const ConversationDrawer = React.lazy(() =>
import('./ConversationDrawer').then(m => ({ default: m.ConversationDrawer }))
);
-const FullConversationDrawer = React.lazy(() =>
- import('./FullConversationDrawer').then(m => ({ default: m.FullConversationDrawer }))
-);
import { useToast } from '~/lib/toast-provider';
import { theme } from '~/lib/theme';
import type { Media } from '../../lib/types';
@@ -32,21 +31,21 @@ import { extractMediaFromBody, removeVideoLinksFromBody } from '~/lib/utils';
const formatTimeAbbreviated = (date: Date): string => {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
-
+
if (diffInSeconds < 60) return '1m'; // Less than a minute, show 1m
-
+
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m`;
-
+
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h`;
-
+
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) return `${diffInDays}d`;
-
+
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) return `${diffInMonths}mo`;
-
+
const diffInYears = Math.floor(diffInMonths / 12);
return `${diffInYears}y`;
};
@@ -54,11 +53,16 @@ const formatTimeAbbreviated = (date: Date): string => {
interface PostCardProps {
post: Discussion;
currentUsername: string | null;
+ isStatic?: boolean;
+ onOpenConversation?: (post: Discussion) => void;
}
-export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) => {
+export const PostCard = React.memo(({ post, currentUsername, isStatic, onOpenConversation }: PostCardProps) => {
+ const { isScrollLocked, setScrollLocked } = useScrollLock();
const { session, followingList, updateUserRelationship } = useAuth();
+ const { settings } = useAppSettings();
+ const [isFollowing, setIsFollowing] = useState(false);
const { estimateVoteValue, isLoading: isVoteValueLoading } = useVoteValue(currentUsername);
const { isItemVisible, registerItem, unregisterItem } = useViewportTracker();
const [isModalVisible, setIsModalVisible] = useState(false);
@@ -67,33 +71,32 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
const [showSlider, setShowSlider] = useState(false);
const [voteWeight, setVoteWeight] = useState(100);
const [isLiked, setIsLiked] = useState(false);
- const [isConversationDrawerVisible, setIsConversationDrawerVisible] = useState(false);
- const [isFullConversationVisible, setIsFullConversationVisible] = useState(false);
+ const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
const [selectedReportReason, setSelectedReportReason] = useState('');
const [reportAdditionalInfo, setReportAdditionalInfo] = useState('');
const [isSubmittingReport, setIsSubmittingReport] = useState(false);
-
+
// Register/unregister with viewport tracker
useEffect(() => {
registerItem(post.permlink);
return () => unregisterItem(post.permlink);
}, [post.permlink, registerItem, unregisterItem]);
-
+
// Check if this post is currently visible
const isVisible = isItemVisible(post.permlink);
-
+
// Memoize expensive calculations
- const initialVoteCount = useMemo(() =>
+ const initialVoteCount = useMemo(() =>
Array.isArray(post.active_votes)
? post.active_votes.filter((vote: any) => vote.weight > 0).length
: 0,
[post.active_votes]
);
-
+
const [voteCount, setVoteCount] = useState(initialVoteCount);
-
+
// Memoize payout value calculation
const initialPayoutValue = useMemo(() => {
const pending = parseFloat(post.pending_payout_value?.toString?.() || '0');
@@ -101,24 +104,14 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
const curator = parseFloat(post.curator_payout_value?.toString?.() || '0');
return pending + total + curator;
}, [post.pending_payout_value, post.total_payout_value, post.curator_payout_value]);
-
+
// Track the post's payout value for dynamic updates
const [payoutValue, setPayoutValue] = useState(initialPayoutValue);
const { showToast } = useToast();
- // Memoize media extraction
- const media = useMemo(() => extractMediaFromBody(post.body), [post.body]);
-
- // Memoize post content processing - remove iframes, images, and video links
- const postContent = useMemo(() => {
- let content = post.body;
- // Remove iframes and images
- content = content.replace(/|!\[.*?\]\(.*?\)/g, '');
- // Remove plain video URLs (YouTube and Odysee)
- content = removeVideoLinksFromBody(content);
- return content.trim();
- }, [post.body]);
-
+ // Use raw body as the new UniversalRenderer handles multimedia tokenization internally
+ const postContent = post.body;
+
// Memoize formatted date
const formattedDate = useMemo(() => {
const dateStr = post.created;
@@ -131,16 +124,47 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
// Check if user has already voted on this post
useEffect(() => {
if (currentUsername && Array.isArray(post.active_votes)) {
- const hasVoted = post.active_votes.some((vote: any) => vote.voter === currentUsername && vote.weight > 0);
+ const hasVoted = post.active_votes.some((vote: any) =>
+ vote.voter.toLowerCase() === currentUsername.toLowerCase() && vote.weight > 0
+ );
setIsLiked(hasVoted);
}
}, [post.active_votes, currentUsername]);
+ // Sync following status
+ useEffect(() => {
+ if (followingList && post.author) {
+ const following = followingList.some(u => u.toLowerCase() === post.author.toLowerCase());
+ setIsFollowing(following);
+ }
+ }, [followingList, post.author]);
+
const handleMediaPress = useCallback((media: Media) => {
setSelectedMedia(media);
setIsModalVisible(true);
}, []);
+ const handleFollow = async () => {
+ if (!currentUsername || currentUsername === "SPECTATOR" || !session?.decryptedKey) {
+ showToast('Please login first', 'error');
+ return;
+ }
+
+ try {
+ setIsFollowing(true);
+ const success = await updateUserRelationship(post.author, 'blog');
+ if (success) {
+ showToast(`Following @${post.author}`, 'success');
+ } else {
+ showToast('Failed to follow user', 'error');
+ }
+ } catch (error) {
+ showToast('Error following user', 'error');
+ } finally {
+ setIsFollowing(false);
+ }
+ };
+
const handleVote = async (customWeight?: number) => {
try {
setIsVoting(true);
@@ -161,7 +185,7 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
// Calculate vote value before submitting
const votePercentage = customWeight ?? voteWeight;
let estimatedValue = 0;
-
+
try {
if (!isVoteValueLoading) {
estimatedValue = await estimateVoteValue(votePercentage);
@@ -174,10 +198,10 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
const previousLikedState = isLiked;
const previousVoteCount = voteCount;
const previousPayoutValue = payoutValue;
-
+
setIsLiked(!isLiked);
setVoteCount(prevCount => previousLikedState ? prevCount - 1 : prevCount + 1);
-
+
// Update payout value if we have an estimation and user is voting (not unvoting)
if (estimatedValue > 0 && !previousLikedState) {
setPayoutValue(prev => prev + estimatedValue);
@@ -191,7 +215,7 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
post.permlink,
previousLikedState ? 0 : Math.round(votePercentage * 100)
);
-
+
// Show simple success toast
showToast('Vote submitted!', 'success');
} catch (err) {
@@ -210,6 +234,7 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
} finally {
setIsVoting(false);
setShowSlider(false);
+ setScrollLocked(false);
}
};
@@ -232,11 +257,19 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
};
const handleConversationPress = () => {
- setIsConversationDrawerVisible(true);
+ if (onOpenConversation) {
+ onOpenConversation(post);
+ } else {
+ setIsDrawerVisible(true);
+ }
};
const handleBodyPress = () => {
- setIsFullConversationVisible(true);
+ if (onOpenConversation) {
+ onOpenConversation(post);
+ } else {
+ setIsDrawerVisible(true);
+ }
};
const handleUserMenuPress = () => {
@@ -299,7 +332,7 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
try {
setIsSubmittingReport(true);
-
+
const success = await submitEncryptedReport(
session.decryptedKey!,
session.username,
@@ -334,18 +367,25 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
<>
{/* Two-column layout: Profile pic | Everything else */}
-
+
{/* Left column: Profile pic only */}
-
+
-
+
{/* Right column: All content */}
{/* Header with author and date */}
@@ -356,71 +396,91 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
{formattedDate}
-
+
+ {/* Follow Button */}
+ {currentUsername &&
+ post.author.toLowerCase() !== currentUsername.toLowerCase() &&
+ !isFollowing && (
+
+ {isFollowing ? (
+
+ ) : (
+ Follow
+ )}
+
+ )}
+
{/* Three dots menu - only show if not viewing own post */}
{currentUsername && post.author !== currentUsername && (
-
+
)}
- {/* Content */}
+ {/* Content and Media handled by UniversalRenderer */}
{postContent !== '' && (
-
+
)}
- {/* Media - outside Pressable so clicks don't open conversation */}
- {media.length > 0 && (
-
- setIsModalVisible(false)}
- isVisible={isVisible}
- />
-
- )}
-
- {/* Full-width action bar — outside mainLayout for better thumb reach */}
-
+ {/* Full-width action bar */}
+
{showSlider ? (
- /* Voting slider mode - takes entire bottom bar */
+ /* Voting mode - takes entire bottom bar */
-
+ {settings.useVoteSlider ? (
+ /* Slider mode */
+
+ ) : (
+ /* Preset buttons mode */
+ handleVote(weight)}
+ disabled={isVoting}
+ />
+ )}
setShowSlider(false)}
+ onPress={() => {
+ setShowSlider(false);
+ setScrollLocked(false);
+ }}
disabled={isVoting}
>
- handleVote(voteWeight)}
- disabled={isVoting}
- >
- {isVoting ? (
-
- ) : (
-
- )}
-
+ {settings.useVoteSlider && (
+ handleVote(voteWeight)}
+ disabled={isVoting}
+ >
+ {isVoting ? (
+
+ ) : (
+
+ )}
+
+ )}
) : (
@@ -433,13 +493,16 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
{/* Replies section - clickable to open conversation */}
-
+
{post.children}
{/* Voting section */}
setShowSlider(true)}
+ onPress={() => {
+ setShowSlider(true);
+ setScrollLocked(true);
+ }}
style={[styles.actionItem, isVoting && styles.disabledButton]}
disabled={isVoting}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
@@ -454,8 +517,8 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
{voteCount}
-
@@ -468,24 +531,13 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
- {/* Conversation Drawer - Quick reply only */}
- {isConversationDrawerVisible && (
+ {/* Unified Conversation Drawer - only show if not managed by parent */}
+ {!onOpenConversation && isDrawerVisible && (
setIsConversationDrawerVisible(false)}
- discussion={post}
- />
-
- )}
-
- {/* Full Conversation Drawer - Entire conversation thread */}
- {isFullConversationVisible && (
-
- setIsFullConversationVisible(false)}
- discussion={post}
+ isVisible={isDrawerVisible}
+ onClose={() => setIsDrawerVisible(false)}
+ post={post}
/>
)}
@@ -500,7 +552,7 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
setShowUserMenu(false)}>
@{post.author}
-
+
{followingList.includes(post.author) ? (
Follow
)}
-
+
handleUserAction('mute')}
>
Mute/Block
-
+
handleReportPost()}
@@ -546,9 +598,9 @@ export const PostCard = React.memo(({ post, currentUsername }: PostCardProps) =>
Report Post
@{post.author}
-
+
Reason for reporting:
-
+
{['Spam', 'Harassment or Abuse', 'Inappropriate Content', 'Copyright Violation', 'Misinformation', 'Other'].map((reason) => (
))}
-
+
Additional Information (Optional):
onChangeText={setReportAdditionalInfo}
editable={!isSubmittingReport}
/>
-
+
>
Cancel
-
+
{
if (!hasInteracted) {
@@ -36,10 +39,12 @@ export function VideoWithAutoplay({
return (
-
+ {isInView && (
+
+ )}
{/* Show play button overlay only if interaction is required and video hasn't been interacted with */}
{requireInteraction && !hasInteracted && (
diff --git a/components/Leaderboard/leaderboard.tsx b/components/Leaderboard/leaderboard.tsx
index e098037..fe0c884 100644
--- a/components/Leaderboard/leaderboard.tsx
+++ b/components/Leaderboard/leaderboard.tsx
@@ -1,10 +1,14 @@
import React, { useMemo } from "react";
-import { View, Image, ScrollView, StyleSheet } from "react-native";
+import { View, ScrollView, StyleSheet } from "react-native";
+import { Image } from "expo-image";
import { Text } from "~/components/ui/text";
import { Ionicons } from "@expo/vector-icons";
import { Crown } from "lucide-react-native";
import { useLeaderboard } from "~/lib/hooks/useQueries";
import { theme } from "~/lib/theme";
+import { MatrixRain } from "~/components/ui/loading-effects/MatrixRain";
+import { useAuth } from "~/lib/auth-provider";
+import { LoadingScreen } from "~/components/ui/LoadingScreen";
interface LeaderboardProps {
currentUsername: string | null;
@@ -18,14 +22,23 @@ interface LeaderboardUserInfo {
}
export function Leaderboard({ currentUsername }: LeaderboardProps) {
+ const { mutedList } = useAuth();
const { data: leaderboardData, isLoading, error } = useLeaderboard();
+ const filteredLeaderboardData = useMemo(() => {
+ if (!leaderboardData) return [];
+ if (!mutedList || mutedList.length === 0) return leaderboardData;
+
+ const mutedLower = mutedList.map((u: string) => u.toLowerCase());
+ return leaderboardData.filter(user => !mutedLower.includes(user.hive_author.toLowerCase()));
+ }, [leaderboardData, mutedList]);
+
const { topSkaters, surroundingUsers, currentUserInfo } = useMemo(() => {
- if (!leaderboardData)
+ if (!filteredLeaderboardData || filteredLeaderboardData.length === 0)
return { topSkaters: [], surroundingUsers: [], currentUserInfo: null };
// Updated the map and filter functions to include explicit type annotations for parameters
- const top10 = leaderboardData.slice(0, 10).map((user, index: number) => ({
+ const top10 = filteredLeaderboardData.slice(0, 10).map((user, index: number) => ({
position: index + 1,
id: user.id,
hive_author: user.hive_author,
@@ -36,23 +49,23 @@ export function Leaderboard({ currentUsername }: LeaderboardProps) {
let currentUserInfo = null;
if (currentUsername) {
- const currentUserIndex = leaderboardData.findIndex(
+ const currentUserIndex = filteredLeaderboardData.findIndex(
(user) => user.hive_author === currentUsername
);
if (currentUserIndex !== -1) {
currentUserInfo = {
position: currentUserIndex + 1,
- id: leaderboardData[currentUserIndex].id,
- hive_author: leaderboardData[currentUserIndex].hive_author,
- points: leaderboardData[currentUserIndex].points,
+ id: filteredLeaderboardData[currentUserIndex].id,
+ hive_author: filteredLeaderboardData[currentUserIndex].hive_author,
+ points: filteredLeaderboardData[currentUserIndex].points,
};
if (currentUserIndex > 9) {
const startIndex = currentUserIndex > 14 ? currentUserIndex - 5 : 10;
- const endIndex = Math.min(currentUserIndex + 5, leaderboardData.length - 1);
+ const endIndex = Math.min(currentUserIndex + 5, filteredLeaderboardData.length - 1);
- surroundingUsers = leaderboardData
+ surroundingUsers = filteredLeaderboardData
.slice(startIndex, endIndex + 1)
.map((user, idx: number) => ({
position: startIndex + idx + 1,
@@ -70,14 +83,12 @@ export function Leaderboard({ currentUsername }: LeaderboardProps) {
}
return { topSkaters: top10, surroundingUsers, currentUserInfo };
- }, [leaderboardData, currentUsername]);
+ }, [filteredLeaderboardData, currentUsername]);
if (isLoading) {
return (
-
- Loading leaderboard...
-
+
);
}
@@ -85,8 +96,14 @@ export function Leaderboard({ currentUsername }: LeaderboardProps) {
if (error) {
return (
+
- Error loading leaderboard
+
+ we are collecting data for the leaderboard.
+
+
+ come back later to see our champions
+
);
@@ -95,12 +112,7 @@ export function Leaderboard({ currentUsername }: LeaderboardProps) {
return (
-
-
-
-
- Leaderboard
-
+ {/* Header Removed for more space */}
{topSkaters.map((skater: LeaderboardUserInfo, index: number) => (
@@ -171,6 +183,7 @@ const LeaderboardItem = ({
uri: `https://images.hive.blog/u/${skater.hive_author}/avatar/small`,
}}
style={styles.avatar}
+ transition={200}
/>
{isTop && skater.position === 1 && (
@@ -218,28 +231,19 @@ const styles = StyleSheet.create({
fontSize: theme.fontSizes.md,
fontFamily: theme.fonts.regular,
},
- headerContainer: {
- alignItems: 'center',
- marginBottom: theme.spacing.md,
- },
- iconContainer: {
- width: 96,
- height: 96,
- borderRadius: 48,
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 3,
- borderColor: theme.colors.border,
- },
- title: {
- fontSize: theme.fontSizes.xxxl,
- fontWeight: 'bold',
- marginTop: theme.spacing.sm,
+ matrixErrorText: {
color: theme.colors.text,
+ fontSize: theme.fontSizes.lg,
fontFamily: theme.fonts.bold,
- paddingTop: theme.spacing.sm, // Add padding to prevent cutoff
- lineHeight: 40, // Ensure proper line height
+ textAlign: "center",
+ textTransform: "lowercase",
+ paddingHorizontal: theme.spacing.xl,
+ textShadowColor: "rgba(0, 255, 0, 0.5)",
+ textShadowOffset: { width: 0, height: 0 },
+ textShadowRadius: 10,
+ },
+ headerContainer: {
+ display: 'none',
},
listContainer: {
paddingHorizontal: theme.spacing.md,
diff --git a/components/Profile/FollowersModal.tsx b/components/Profile/FollowersModal.tsx
index 4fd6f79..d0eb740 100644
--- a/components/Profile/FollowersModal.tsx
+++ b/components/Profile/FollowersModal.tsx
@@ -12,8 +12,9 @@ import { router } from 'expo-router';
import { FontAwesome } from '@expo/vector-icons';
import { Text } from '../ui/text';
import { theme } from '~/lib/theme';
-import { getFollowing, getFollowers, getMuted, setUserRelationship } from '~/lib/hive-utils';
+import { getFollowing, getFollowers, setUserRelationship } from '~/lib/hive-utils';
import { useAuth } from '~/lib/auth-provider';
+import { getMutedList, getBlacklistedList } from '~/lib/api';
interface FollowersModalProps {
visible: boolean;
@@ -95,8 +96,12 @@ export const FollowersModal: React.FC = ({
newUsers = await getFollowers(username, startFrom, 50);
} else if (type === 'following') {
newUsers = await getFollowing(username, startFrom, 50);
+ } else if (type === 'muted') {
+ newUsers = await getMutedList(username);
+ setHasMore(false); // getMutedList returns the whole list for now
} else {
- newUsers = await getMuted(username, startFrom, 50);
+ newUsers = await getBlacklistedList(username);
+ setHasMore(false);
}
if (append) {
@@ -105,8 +110,10 @@ export const FollowersModal: React.FC = ({
setUsers(newUsers);
}
- // If we got less than 50, we've reached the end
- setHasMore(newUsers.length === 50);
+ // If we got fewer results than the page size, we've reached the end
+ if (type === 'followers' || type === 'following') {
+ setHasMore(newUsers.length === 50);
+ }
} catch (error) {
console.error(`Error loading ${type}:`, error);
} finally {
diff --git a/components/SpectatorMode/RewardsSpectatorInfo.tsx b/components/SpectatorMode/RewardsSpectatorInfo.tsx
index bf34d54..dea6650 100644
--- a/components/SpectatorMode/RewardsSpectatorInfo.tsx
+++ b/components/SpectatorMode/RewardsSpectatorInfo.tsx
@@ -19,7 +19,7 @@ export function RewardsSpectatorInfo() {
{
icon: "journal-outline" as IconName,
title: "Magazine Rewards",
- text: "Participate in SkateHive's Magazine and earn extra incentives for your contributions.",
+ text: "Participate in Skatehive's Magazine and earn extra incentives for your contributions.",
},
{
icon: "ribbon-outline" as IconName,
@@ -33,7 +33,7 @@ export function RewardsSpectatorInfo() {
icon="megaphone-outline"
iconColor="#34C759"
title="Rewards"
- description="Learn how you can earn rewards in the SkateHive community!"
+ description="Learn how you can earn rewards in the Skatehive community!"
infoItems={rewardsInfoItems}
/>
);
diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx
index e51888a..7a2a742 100644
--- a/components/auth/LoginForm.tsx
+++ b/components/auth/LoginForm.tsx
@@ -21,8 +21,6 @@ interface LoginFormProps {
onSpectator: () => Promise;
storedUsers?: StoredUser[];
onQuickLogin?: (username: string, method: EncryptionMethod, pin?: string) => Promise;
- onDeleteUser?: (username: string) => void;
- deletingUser?: string | null;
}
export function LoginForm({
@@ -35,8 +33,6 @@ export function LoginForm({
onSpectator,
storedUsers = [],
onQuickLogin,
- onDeleteUser,
- deletingUser,
}: LoginFormProps) {
const [method, setMethod] = useState('pin');
const [pin, setPin] = useState('');
@@ -172,11 +168,8 @@ export function LoginForm({
- {deletingUser && (
- Deleting @{deletingUser}...
- )}
+
+ Add a new user
@@ -263,7 +256,7 @@ export function LoginForm({
disabled={isLoading}
>
- {isLoading ? 'Logging in...' : 'Login'}
+ Login
@@ -275,21 +268,14 @@ export function LoginForm({
)}
- {!hasStoredUsers && (
-
- Enter as Spectator
-
- )}
-
WebBrowser.openBrowserAsync('https://hive.io')}
- style={styles.textLink}
+ onPress={onSpectator}
+ style={styles.secondaryButton}
>
- More info
+ Enter as Spectator
+
+ {/* "More info" moved to About screen as requested */}
>
)}
@@ -302,11 +288,8 @@ export function LoginForm({
) : null}
- {isLoading && (
-
- Authenticating...
-
- )}
+
+ {/* Suppressed "Authenticating..." text as requested */}
);
}
diff --git a/components/auth/StoredUsersView.tsx b/components/auth/StoredUsersView.tsx
index 349f2f3..21bbdfa 100644
--- a/components/auth/StoredUsersView.tsx
+++ b/components/auth/StoredUsersView.tsx
@@ -9,10 +9,9 @@ import type { StoredUser } from '../../lib/types';
interface StoredUsersViewProps {
users: StoredUser[];
onQuickLogin: (user: StoredUser) => void;
- onDeleteUser?: (username: string) => void;
}
-export function StoredUsersView({ users, onQuickLogin, onDeleteUser }: StoredUsersViewProps) {
+export function StoredUsersView({ users, onQuickLogin }: StoredUsersViewProps) {
return (
{users
- .filter(user => user.username !== "SPECTATOR")
- .map((user) => (
+ .filter(user => user.username && user.username.trim() !== "" && user.username !== "SPECTATOR")
+ .map((user, index) => (
- {user.username}
+ @{user.username}
- {onDeleteUser && (
- onDeleteUser(user.username)}
- style={styles.deleteButton}
- accessibilityLabel={`Delete @${user.username}`}
- hitSlop={8}
- >
-
-
- )}
))}
diff --git a/components/markdown/EmbedFactory.tsx b/components/markdown/EmbedFactory.tsx
new file mode 100644
index 0000000..cd68f0d
--- /dev/null
+++ b/components/markdown/EmbedFactory.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { VideoEmbed, VideoType } from './embeds/VideoEmbed';
+import { InstagramEmbed } from './embeds/InstagramEmbed';
+import { ZoraEmbed } from './embeds/ZoraEmbed';
+import { SnapshotEmbed } from './embeds/SnapshotEmbed';
+import { ImageEmbed } from './embeds/ImageEmbed';
+import { Registry } from '~/lib/markdown/providers';
+
+interface EmbedFactoryProps {
+ token: string;
+ isVisible?: boolean;
+}
+
+export const EmbedFactory = ({ token, isVisible }: EmbedFactoryProps) => {
+ // Token format: [[TYPE:ID]] - allow some whitespace and case-insensitive
+ // Aliasing IAMGE to IMAGE to handle common typos
+ const match = token.match(/^\s*\[\[(YOUTUBE|VIMEO|ODYSEE|THREESPEAK|IPFSVIDEO|INSTAGRAM|ZORACOIN|SNAPSHOT|IMAGE|IAMGE):([^\]]+)\]\]\s*$/i);
+
+ if (!match) return null;
+
+ let type = match[1].toUpperCase();
+ const id = match[2].trim();
+
+ if (type === 'IAMGE') type = 'IMAGE';
+
+ // A. Check for Modular Provider first
+ const provider = Registry.getProvider(type);
+ if (provider) {
+ return ;
+ }
+
+ // B. Fallback to Legacy Switch
+ switch (type) {
+ case 'YOUTUBE':
+ case 'VIMEO':
+ case 'ODYSEE':
+ case 'THREESPEAK':
+ case 'IPFSVIDEO':
+ return ;
+ case 'INSTAGRAM':
+ return ;
+ case 'ZORACOIN':
+ return ;
+ case 'SNAPSHOT':
+ return ;
+ case 'IMAGE':
+ return ;
+ default:
+ return null;
+ }
+};
diff --git a/components/markdown/EnhancedMarkdownRenderer.tsx b/components/markdown/EnhancedMarkdownRenderer.tsx
index b8cf67a..05720f2 100644
--- a/components/markdown/EnhancedMarkdownRenderer.tsx
+++ b/components/markdown/EnhancedMarkdownRenderer.tsx
@@ -1,262 +1,15 @@
-import React, { useMemo } from 'react';
-import { View, Text, StyleSheet } from 'react-native';
-import { theme } from '~/lib/theme';
-
-export interface ProcessedMarkdown {
- originalContent: string;
- processedContent: string;
- contentWithPlaceholders: string;
- hasInstagramEmbeds: boolean;
- videoPlaceholders: VideoPlaceholder[];
-}
-
-export interface VideoPlaceholder {
- type: 'VIDEO' | 'ODYSEE' | 'YOUTUBE' | 'VIMEO' | 'ZORACOIN';
- id: string;
- placeholder: string;
-}
+import React from 'react';
+import { UniversalRenderer } from './UniversalRenderer';
interface EnhancedMarkdownRendererProps {
content: string;
+ isVisible?: boolean;
}
-export function EnhancedMarkdownRenderer({
- content,
-}: EnhancedMarkdownRendererProps) {
-
- const processedMarkdown = useMemo(() => {
- return MarkdownProcessor.process(content);
- }, [content]);
-
- const renderedContent = useMemo(() => {
- return renderContentWithVideos(processedMarkdown);
- }, [processedMarkdown]);
-
- const styles = StyleSheet.create({
- container: {
- width: '100%',
- },
- });
-
- return {renderedContent} ;
-}
-
-// Simple MarkdownProcessor class for React Native
-class MarkdownProcessor {
- static process(content: string): ProcessedMarkdown {
- // For now, use the basic markdown renderer
- const processedContent = content;
- const contentWithPlaceholders = content;
- const hasInstagramEmbeds = content.includes('instagram.com');
- const videoPlaceholders: VideoPlaceholder[] = [];
-
- return {
- originalContent: content,
- processedContent,
- contentWithPlaceholders,
- hasInstagramEmbeds,
- videoPlaceholders,
- };
- }
-}
-
-function renderContentWithVideos(
- processed: ProcessedMarkdown
-): React.ReactNode[] {
- const styles = StyleSheet.create({
- // Blockquote styles
- quoteContainer: {
- flexDirection: 'row',
- marginVertical: theme.spacing.xs,
- paddingLeft: theme.spacing.sm,
- },
- quoteBorder: {
- width: 3,
- backgroundColor: theme.colors.green,
- marginRight: theme.spacing.sm,
- borderRadius: 2,
- },
- quoteText: {
- fontSize: theme.fontSizes.md,
- color: theme.colors.text,
- fontStyle: 'italic',
- flex: 1,
- fontFamily: theme.fonts.default,
- },
- // Header styles
- h1Text: {
- fontSize: theme.fontSizes.xl,
- fontWeight: 'bold',
- color: theme.colors.text,
- marginVertical: theme.spacing.sm,
- fontFamily: theme.fonts.bold,
- },
- h2Text: {
- fontSize: theme.fontSizes.lg,
- fontWeight: 'bold',
- color: theme.colors.text,
- marginVertical: theme.spacing.xs,
- fontFamily: theme.fonts.bold,
- },
- h3Text: {
- fontSize: theme.fontSizes.md,
- fontWeight: 'bold',
- color: theme.colors.text,
- marginVertical: theme.spacing.xs,
- fontFamily: theme.fonts.bold,
- },
- // Text formatting styles
- boldText: {
- fontWeight: 'bold',
- fontSize: theme.fontSizes.md,
- fontFamily: theme.fonts.bold,
- },
- italicText: {
- fontStyle: 'italic',
- fontSize: theme.fontSizes.md,
- },
- contentText: {
- fontSize: theme.fontSizes.md,
- color: theme.colors.text,
- lineHeight: 20,
- fontFamily: theme.fonts.default,
- },
- // Link styles
- linkText: {
- fontSize: theme.fontSizes.md,
- color: theme.colors.green,
- textDecorationLine: 'underline',
- },
- // Code styles
- codeText: {
- fontFamily: theme.fonts.default,
- fontSize: theme.fontSizes.sm,
- backgroundColor: theme.colors.lightGray,
- color: theme.colors.text,
- paddingHorizontal: theme.spacing.xs,
- paddingVertical: 2,
- borderRadius: theme.borderRadius.xs,
- },
- codeBlock: {
- fontFamily: theme.fonts.default,
- fontSize: theme.fontSizes.sm,
- backgroundColor: theme.colors.lightGray,
- color: theme.colors.text,
- padding: theme.spacing.md,
- borderRadius: theme.borderRadius.md,
- marginVertical: theme.spacing.sm,
- },
- });
-
- // Split content into lines and process each line
- const lines = processed.contentWithPlaceholders.split('\n');
- const renderedLines: React.ReactNode[] = [];
-
- lines.forEach((line, lineIndex) => {
- // Handle blockquotes (lines starting with >)
- if (line.trim().startsWith('>')) {
- const quoteText = line.replace(/^>\s*/, '');
- renderedLines.push(
-
-
- {quoteText}
-
- );
- }
- // Handle headers (lines starting with #)
- else if (line.trim().startsWith('#')) {
- const headerLevel = line.match(/^#+/)?.[0].length || 1;
- const headerText = line.replace(/^#+\s*/, '');
- const headerStyle = headerLevel === 1 ? styles.h1Text :
- headerLevel === 2 ? styles.h2Text : styles.h3Text;
- renderedLines.push(
- {headerText}
- );
- }
- // Handle code blocks (lines with ``` or starting with 4 spaces)
- else if (line.trim().startsWith('```') || line.startsWith(' ')) {
- const codeText = line.replace(/^```|```$/g, '').replace(/^ /, '');
- if (codeText.trim()) {
- renderedLines.push(
- {codeText}
- );
- }
- }
- // Handle bold text (**text**)
- else if (line.includes('**')) {
- const parts = line.split(/(\*\*.*?\*\*)/g);
- renderedLines.push(
-
- {parts.map((part, partIndex) => {
- if (part.startsWith('**') && part.endsWith('**')) {
- const boldText = part.slice(2, -2);
- return {boldText} ;
- }
- return {part} ;
- })}
-
- );
- }
- // Handle italic text (*text*)
- else if (line.includes('*') && !line.includes('**')) {
- const parts = line.split(/(\*.*?\*)/g);
- renderedLines.push(
-
- {parts.map((part, partIndex) => {
- if (part.startsWith('*') && part.endsWith('*') && !part.includes('**')) {
- const italicText = part.slice(1, -1);
- return {italicText} ;
- }
- return {part} ;
- })}
-
- );
- }
- // Handle inline code (`text`)
- else if (line.includes('`') && !line.includes('```')) {
- const parts = line.split(/(`.*?`)/g);
- renderedLines.push(
-
- {parts.map((part, partIndex) => {
- if (part.startsWith('`') && part.endsWith('`')) {
- const codeText = part.slice(1, -1);
- return {codeText} ;
- }
- return {part} ;
- })}
-
- );
- }
- // Regular text
- else if (line.trim()) {
- // Handle URLs in the text
- const urlRegex = /(https?:\/\/[^\s]+)/g;
- const parts = line.split(urlRegex);
-
- if (parts.length > 1) {
- renderedLines.push(
-
- {parts.map((part, partIndex) => {
- if (urlRegex.test(part)) {
- return {part} ;
- }
- return {part} ;
- })}
-
- );
- } else {
- renderedLines.push(
- {line}
- );
- }
- }
- // Empty line (add spacing)
- else {
- renderedLines.push(
-
- );
- }
- });
-
- return renderedLines;
+/**
+ * @deprecated Use UniversalRenderer directly if possible.
+ * This component is kept for backward compatibility and now uses the new modular system.
+ */
+export function EnhancedMarkdownRenderer({ content, isVisible }: EnhancedMarkdownRendererProps) {
+ return ;
}
diff --git a/components/markdown/UniversalRenderer.tsx b/components/markdown/UniversalRenderer.tsx
new file mode 100644
index 0000000..aab3146
--- /dev/null
+++ b/components/markdown/UniversalRenderer.tsx
@@ -0,0 +1,148 @@
+import React, { useMemo } from 'react';
+import { View, StyleSheet, ScrollView } from 'react-native';
+import Markdown from 'react-native-markdown-display';
+import { MarkdownProcessor } from '~/lib/markdown/MarkdownProcessor';
+import { EmbedFactory } from './EmbedFactory';
+import { theme } from '~/lib/theme';
+
+interface UniversalRendererProps {
+ content: string;
+ isVisible?: boolean;
+}
+
+export const UniversalRenderer = ({ content, isVisible }: UniversalRendererProps) => {
+ const processed = useMemo(() => MarkdownProcessor.process(content), [content]);
+
+ // Split by internal token placeholders [[TYPE:ID]]
+ const parts = useMemo(() => {
+ return processed.contentWithPlaceholders.split(/(\s*\[\[[A-Z]+:[^\]]+\]\]\s*)/g);
+ }, [processed.contentWithPlaceholders]);
+
+ const markdownStyles = useMemo(() => StyleSheet.create({
+ body: {
+ color: theme.colors.text,
+ fontFamily: theme.fonts.default,
+ fontSize: theme.fontSizes.md,
+ lineHeight: 22,
+ },
+ link: {
+ color: theme.colors.green,
+ textDecorationLine: 'underline',
+ },
+ blockquote: {
+ backgroundColor: 'rgba(50, 205, 50, 0.05)',
+ borderLeftColor: theme.colors.green,
+ borderLeftWidth: 4,
+ marginLeft: 0,
+ paddingHorizontal: theme.spacing.sm,
+ paddingVertical: theme.spacing.xs,
+ },
+ code_inline: {
+ backgroundColor: theme.colors.lightGray,
+ color: theme.colors.text,
+ fontFamily: theme.fonts.default,
+ borderRadius: theme.borderRadius.xs,
+ paddingHorizontal: 4,
+ },
+ code_block: {
+ backgroundColor: theme.colors.lightGray,
+ color: theme.colors.text,
+ fontFamily: theme.fonts.default,
+ borderRadius: theme.borderRadius.sm,
+ padding: theme.spacing.sm,
+ marginVertical: theme.spacing.sm,
+ },
+ heading1: {
+ color: theme.colors.text,
+ fontFamily: theme.fonts.bold,
+ fontSize: theme.fontSizes.xl,
+ marginVertical: theme.spacing.sm,
+ },
+ heading2: {
+ color: theme.colors.text,
+ fontFamily: theme.fonts.bold,
+ fontSize: theme.fontSizes.lg,
+ marginVertical: theme.spacing.xs,
+ },
+ image: {
+ width: '100%',
+ height: 200,
+ borderRadius: theme.borderRadius.md,
+ }
+ }), []);
+
+ // Simple hash for stable keys
+ const getStableKey = (str: string) => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash << 5) - hash + str.charCodeAt(i);
+ hash |= 0;
+ }
+ return `part-${hash}`;
+ };
+
+ // Create renderable items by grouping consecutive text parts
+ const renderItems = useMemo(() => {
+ const items: { type: 'token' | 'markdown'; content: string; key: string }[] = [];
+ let currentMarkdown = '';
+
+ parts.forEach((part, index) => {
+ if (!part) return;
+
+ const trimmedPart = part.trim();
+ if (trimmedPart.startsWith('[[') && trimmedPart.endsWith(']]')) {
+ // If we have accumulated markdown, push it first
+ if (currentMarkdown.length > 0) {
+ items.push({
+ type: 'markdown',
+ content: currentMarkdown,
+ key: `md-${index}-${getStableKey(currentMarkdown)}`
+ });
+ currentMarkdown = '';
+ }
+ // Push the token
+ items.push({
+ type: 'token',
+ content: trimmedPart,
+ key: `token-${index}-${getStableKey(trimmedPart)}`
+ });
+ } else {
+ // Accumulate text/markdown including whitespace
+ currentMarkdown += part;
+ }
+ });
+
+ // Push any remaining markdown
+ if (currentMarkdown.length > 0) {
+ items.push({
+ type: 'markdown',
+ content: currentMarkdown,
+ key: `md-final-${getStableKey(currentMarkdown)}`
+ });
+ }
+
+ return items;
+ }, [parts, getStableKey]);
+
+ return (
+
+ {renderItems.map((item) => {
+ if (item.type === 'token') {
+ return ;
+ }
+ return (
+
+ {item.content}
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ width: '100%',
+ },
+});
diff --git a/components/markdown/embeds/BaseVideoEmbed.tsx b/components/markdown/embeds/BaseVideoEmbed.tsx
new file mode 100644
index 0000000..e9f5879
--- /dev/null
+++ b/components/markdown/embeds/BaseVideoEmbed.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { View, StyleSheet, ActivityIndicator } from 'react-native';
+import { WebView } from 'react-native-webview';
+import { theme } from '~/lib/theme';
+
+interface BaseVideoEmbedProps {
+ url: string;
+ isVisible?: boolean;
+}
+
+export const BaseVideoEmbed = ({ url, isVisible }: BaseVideoEmbedProps) => {
+ const [loading, setLoading] = React.useState(true);
+
+ if (!url) return null;
+
+ // Lazy mounting: Only render WebView if visible
+ if (!isVisible) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ setLoading(false)}
+ // Defensive script to pause any elements that try to bypass policy
+ injectedJavaScript={`
+ (function() {
+ var videos = document.getElementsByTagName('video');
+ for (var i = 0; i < videos.length; i++) {
+ videos[i].pause();
+ videos[i].autoplay = false;
+ }
+ })();
+ true;
+ `}
+ />
+ {loading && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ aspectRatio: 16 / 9,
+ backgroundColor: '#000',
+ marginVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.md,
+ overflow: 'hidden',
+ },
+ webview: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ loading: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#000',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/markdown/embeds/ImageEmbed.tsx b/components/markdown/embeds/ImageEmbed.tsx
new file mode 100644
index 0000000..75ee2e6
--- /dev/null
+++ b/components/markdown/embeds/ImageEmbed.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { StyleSheet, Pressable, View, Dimensions, Modal } from 'react-native';
+import { Image } from 'expo-image';
+import { theme } from '~/lib/theme';
+import { Ionicons } from '@expo/vector-icons';
+
+interface ImageEmbedProps {
+ url: string;
+}
+
+const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
+
+export const ImageEmbed = ({ url }: ImageEmbedProps) => {
+ const [aspectRatio, setAspectRatio] = React.useState(1.0); // Predictable initial
+ const [isModalVisible, setIsModalVisible] = React.useState(false);
+
+ return (
+
+ setIsModalVisible(true)}>
+ {
+ if (e.source.width && e.source.height) {
+ setAspectRatio(e.source.width / e.source.height);
+ }
+ }}
+ />
+
+
+ setIsModalVisible(false)}
+ >
+
+ setIsModalVisible(false)}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ marginVertical: theme.spacing.sm,
+ borderRadius: theme.borderRadius.md,
+ overflow: 'hidden',
+ backgroundColor: 'rgba(255, 255, 255, 0.05)', // Subtle placeholder
+ },
+ image: {
+ width: '100%',
+ backgroundColor: 'transparent',
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: '#000000',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ fullImage: {
+ width: screenWidth,
+ height: screenHeight,
+ },
+ closeButton: {
+ position: 'absolute',
+ top: 50,
+ right: 20,
+ zIndex: 10,
+ padding: 10,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ borderRadius: 20,
+ }
+});
diff --git a/components/markdown/embeds/InstagramEmbed.tsx b/components/markdown/embeds/InstagramEmbed.tsx
new file mode 100644
index 0000000..05defe8
--- /dev/null
+++ b/components/markdown/embeds/InstagramEmbed.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { View, StyleSheet, ActivityIndicator } from 'react-native';
+import { WebView } from 'react-native-webview';
+import { theme } from '~/lib/theme';
+
+interface InstagramEmbedProps {
+ url: string;
+}
+
+export const InstagramEmbed = ({ url }: InstagramEmbedProps) => {
+ const [loading, setLoading] = React.useState(true);
+
+ // Clean URL to ensure it's an embeddable one
+ const embedUrl = url.endsWith('/') ? `${url}embed` : `${url}/embed`;
+
+ return (
+
+ setLoading(false)}
+ />
+ {loading && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ height: 450, // Instagram embeds usually need a fixed height or dynamic calculation
+ backgroundColor: theme.colors.card,
+ marginVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.md,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: theme.colors.lightGray,
+ },
+ webview: {
+ flex: 1,
+ },
+ loading: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: theme.colors.card,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/markdown/embeds/SnapshotEmbed.tsx b/components/markdown/embeds/SnapshotEmbed.tsx
new file mode 100644
index 0000000..37c80f1
--- /dev/null
+++ b/components/markdown/embeds/SnapshotEmbed.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { View, StyleSheet, ActivityIndicator } from 'react-native';
+import { WebView } from 'react-native-webview';
+import { theme } from '~/lib/theme';
+
+interface SnapshotEmbedProps {
+ url: string;
+}
+
+export const SnapshotEmbed = ({ url }: SnapshotEmbedProps) => {
+ const [loading, setLoading] = React.useState(true);
+
+ return (
+
+ setLoading(false)}
+ />
+ {loading && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ height: 500,
+ backgroundColor: theme.colors.card,
+ marginVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.md,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: theme.colors.lightGray,
+ },
+ webview: {
+ flex: 1,
+ },
+ loading: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: theme.colors.card,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/markdown/embeds/VideoEmbed.tsx b/components/markdown/embeds/VideoEmbed.tsx
new file mode 100644
index 0000000..b107739
--- /dev/null
+++ b/components/markdown/embeds/VideoEmbed.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { View, StyleSheet, ActivityIndicator } from 'react-native';
+import { WebView } from 'react-native-webview';
+import { theme } from '~/lib/theme';
+import { BaseVideoEmbed } from './BaseVideoEmbed';
+
+export type VideoType = 'YOUTUBE' | 'VIMEO' | 'ODYSEE' | 'THREESPEAK' | 'IPFSVIDEO';
+
+interface VideoEmbedProps {
+ type: VideoType;
+ id: string;
+ isVisible?: boolean;
+}
+
+export const VideoEmbed = ({ type, id, isVisible }: VideoEmbedProps) => {
+ const getEmbedUrl = () => {
+ switch (type) {
+ case 'YOUTUBE':
+ return `https://www.youtube.com/embed/${id}?rel=0&modestbranding=1&playsinline=1&enablejsapi=1&autoplay=0&mute=1&origin=https://skatehive.app`;
+ case 'VIMEO':
+ return `https://player.vimeo.com/video/${id}?autoplay=0&muted=1&origin=https://skatehive.app`;
+ case 'THREESPEAK':
+ return `https://play.3speak.tv/watch?v=${id}&mode=iframe&autoplay=0&muted=1`;
+ case 'ODYSEE':
+ let odyseeBase = '';
+ if (id.includes('odysee.com/$/embed')) {
+ odyseeBase = id;
+ } else if (id.startsWith('http')) {
+ const match = id.match(/odysee\.com\/(?:[^\/]+\/)?([\w@:%._\+~#=\/-]+)/i);
+ const cleanId = match ? match[1] : id;
+ odyseeBase = `https://odysee.com/$/embed/${cleanId}`;
+ } else {
+ odyseeBase = `https://odysee.com/$/embed/${id}`;
+ }
+ return odyseeBase.includes('?') ? `${odyseeBase}&autoplay=false&muted=true` : `${odyseeBase}?autoplay=false&muted=true`;
+ case 'IPFSVIDEO':
+ const ipfsUrl = id.includes('https') ? id : `https://ipfs.skatehive.app/ipfs/${id}`;
+ return ipfsUrl.includes('?') ? `${ipfsUrl}&autoplay=0&muted=1` : `${ipfsUrl}?autoplay=0&muted=1`;
+ default:
+ return '';
+ }
+ };
+
+ const url = getEmbedUrl();
+ if (!url) return null;
+
+ return ;
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ aspectRatio: 16 / 9,
+ backgroundColor: '#000',
+ marginVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.md,
+ overflow: 'hidden',
+ },
+ webview: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ loading: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#000',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/markdown/embeds/ZoraEmbed.tsx b/components/markdown/embeds/ZoraEmbed.tsx
new file mode 100644
index 0000000..11c0b9e
--- /dev/null
+++ b/components/markdown/embeds/ZoraEmbed.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { View, StyleSheet, ActivityIndicator } from 'react-native';
+import { WebView } from 'react-native-webview';
+import { theme } from '~/lib/theme';
+
+interface ZoraEmbedProps {
+ address: string;
+}
+
+export const ZoraEmbed = ({ address }: ZoraEmbedProps) => {
+ const [loading, setLoading] = React.useState(true);
+
+ // Use the web preview URL for Zora content
+ const zoraUrl = `https://zora.co/coin/${address}`;
+
+ return (
+
+ setLoading(false)}
+ />
+ {loading && (
+
+
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ height: 480,
+ backgroundColor: theme.colors.card,
+ marginVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.lg,
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: theme.colors.green, // Accentuate Web3 content
+ },
+ webview: {
+ flex: 1,
+ },
+ loading: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: theme.colors.card,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+});
diff --git a/components/notifications/NotificationItem.tsx b/components/notifications/NotificationItem.tsx
index e6d5468..f4e8161 100644
--- a/components/notifications/NotificationItem.tsx
+++ b/components/notifications/NotificationItem.tsx
@@ -14,6 +14,8 @@ interface NotificationItemProps {
export const NotificationItem = React.memo(({ notification }: NotificationItemProps) => {
const [isConversationDrawerVisible, setIsConversationDrawerVisible] = useState(false);
+ const [parentAuthor, setParentAuthor] = useState(null);
+ const [parentPermlink, setParentPermlink] = useState(null);
const [postData, setPostData] = useState(null);
// Extract author from the notification message (usually starts with @username)
@@ -227,7 +229,14 @@ export const NotificationItem = React.memo(({ notification }: NotificationItemPr
} as unknown as Discussion;
}
- setPostData(parentDiscussion);
+ if (replyContent && replyContent.parent_author && replyContent.parent_permlink) {
+ setParentAuthor(replyContent.parent_author);
+ setParentPermlink(replyContent.parent_permlink);
+ } else if (postInfo) {
+ setParentAuthor(postInfo.author);
+ setParentPermlink(postInfo.permlink);
+ }
+
setIsConversationDrawerVisible(true);
}
} else {
@@ -280,11 +289,13 @@ export const NotificationItem = React.memo(({ notification }: NotificationItemPr
{/* Conversation Drawer for replies and mentions */}
- {postData && (
+ {(parentAuthor || parentPermlink || postData) && (
setIsConversationDrawerVisible(false)}
- discussion={postData}
+ post={postData || undefined}
+ author={parentAuthor || undefined}
+ permlink={parentPermlink || undefined}
/>
)}
>
diff --git a/components/ui/BadgedIcon.tsx b/components/ui/BadgedIcon.tsx
index 9ef7d40..5d64691 100644
--- a/components/ui/BadgedIcon.tsx
+++ b/components/ui/BadgedIcon.tsx
@@ -31,7 +31,7 @@ const styles = StyleSheet.create({
position: 'relative',
},
icon: {
- marginBottom: -10,
+ // Removed negative margin that caused misalignment
},
badge: {
position: 'absolute',
diff --git a/components/ui/CommentBottomSheet.tsx b/components/ui/CommentBottomSheet.tsx
new file mode 100644
index 0000000..12d0d56
--- /dev/null
+++ b/components/ui/CommentBottomSheet.tsx
@@ -0,0 +1,194 @@
+import React, { useRef, useEffect } from "react";
+import {
+ View,
+ StyleSheet,
+ Pressable,
+ Animated,
+ Dimensions,
+ Easing,
+ KeyboardAvoidingView,
+ Platform,
+ TouchableWithoutFeedback,
+ Keyboard,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { Text } from "~/components/ui/text";
+import { ReplyComposer } from "~/components/ui/ReplyComposer";
+import { theme } from "~/lib/theme";
+
+const { height: SCREEN_HEIGHT } = Dimensions.get("window");
+
+interface CommentBottomSheetProps {
+ isVisible: boolean;
+ onClose: () => void;
+ parentAuthor: string;
+ parentPermlink: string;
+ onReplySuccess?: (newReply: any) => void;
+}
+
+export function CommentBottomSheet({
+ isVisible,
+ onClose,
+ parentAuthor,
+ parentPermlink,
+ onReplySuccess,
+}: CommentBottomSheetProps) {
+ const slideAnim = useRef(new Animated.Value(SCREEN_HEIGHT)).current;
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (isVisible) {
+ Animated.parallel([
+ Animated.timing(slideAnim, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.ease),
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ } else {
+ Keyboard.dismiss();
+ Animated.parallel([
+ Animated.timing(slideAnim, {
+ toValue: SCREEN_HEIGHT,
+ duration: 250,
+ easing: Easing.in(Easing.ease),
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 250,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }
+ }, [isVisible, slideAnim, fadeAnim]);
+
+ if (!isVisible && slideAnim.addListener === undefined) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+
+
+
+
+ {/* Bottom Sheet */}
+
+
+
+
+
+ Comments
+
+
+
+
+
+
+
+
+ Replying to @{parentAuthor}
+
+
+ {
+ if (onReplySuccess) onReplySuccess(newReply);
+ onClose();
+ }}
+ placeholder="Add a comment..."
+ buttonLabel="Post"
+ />
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ zIndex: 100,
+ },
+ keyboardAvoidingView: {
+ ...StyleSheet.absoluteFillObject,
+ justifyContent: "flex-end",
+ zIndex: 101,
+ },
+ sheetContainer: {
+ backgroundColor: theme.colors.card,
+ borderTopLeftRadius: theme.borderRadius.xl,
+ borderTopRightRadius: theme.borderRadius.xl,
+ maxHeight: SCREEN_HEIGHT * 0.9,
+ minHeight: SCREEN_HEIGHT * 0.5,
+ paddingBottom: theme.spacing.xl, // Extra padding for safe area
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: -5 },
+ shadowOpacity: 0.3,
+ shadowRadius: 10,
+ elevation: 20,
+ },
+ header: {
+ padding: theme.spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ alignItems: "center",
+ },
+ handleBar: {
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: theme.colors.border,
+ marginBottom: theme.spacing.sm,
+ },
+ headerTitleContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "100%",
+ },
+ headerTitle: {
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ },
+ closeButton: {
+ position: "absolute",
+ right: theme.spacing.md,
+ top: theme.spacing.md,
+ padding: theme.spacing.xs,
+ },
+ content: {
+ padding: theme.spacing.md,
+ },
+ replyingToText: {
+ fontSize: theme.fontSizes.sm,
+ fontFamily: theme.fonts.regular,
+ color: theme.colors.muted,
+ marginBottom: theme.spacing.sm,
+ },
+ replyingToAuthor: {
+ color: theme.colors.primary,
+ fontFamily: theme.fonts.bold,
+ },
+});
diff --git a/components/ui/GlobalHeader.tsx b/components/ui/GlobalHeader.tsx
new file mode 100644
index 0000000..1329691
--- /dev/null
+++ b/components/ui/GlobalHeader.tsx
@@ -0,0 +1,140 @@
+import React from "react";
+import { View, StyleSheet, Pressable } from "react-native";
+import { Image } from "expo-image";
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { Text } from "~/components/ui/text";
+import { theme } from "~/lib/theme";
+import { useAuth } from "~/lib/auth-provider";
+import useHiveAccount from "~/lib/hooks/useHiveAccount";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useNotificationContext } from "~/lib/notifications-context";
+import { BadgedIcon } from "./BadgedIcon";
+
+interface GlobalHeaderProps {
+ onOpenMenu: () => void;
+ title?: string;
+ centerComponent?: React.ReactNode;
+ showSettings?: boolean;
+}
+
+export function GlobalHeader({ onOpenMenu, title = "Skatehive", centerComponent, showSettings }: GlobalHeaderProps) {
+ const router = useRouter();
+ const { badgeCount } = useNotificationContext();
+
+ const handleSearchPress = () => {
+ router.push("/(tabs)/search" as any);
+ };
+
+ const handleNotificationsPress = () => {
+ router.push("/(tabs)/notifications" as any);
+ };
+
+ return (
+
+
+ {/* Left: Notifications */}
+
+ 0 ? `Notifications, ${badgeCount} unread` : "Notifications"}
+ >
+
+
+
+
+ {/* Center: Title, Logo or Custom Component */}
+
+ {centerComponent || {title} }
+
+
+ {/* Right: Search or Settings */}
+
+ {showSettings ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ backgroundColor: theme.colors.background,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ container: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ height: 56,
+ paddingHorizontal: theme.spacing.md,
+ },
+ leftAction: {
+ width: 48, // Consistent width for alignment
+ height: 48,
+ justifyContent: "center",
+ alignItems: "flex-start",
+ },
+ centerContent: {
+ flex: 1,
+ height: 48,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ rightActions: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "flex-end",
+ minWidth: 80,
+ gap: 4, // Tighter gap for better alignment
+ },
+ iconButton: {
+ width: 40,
+ height: 40,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ titleText: {
+ fontSize: theme.fontSizes.lg,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ },
+ avatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+ defaultAvatar: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: theme.colors.card,
+ alignItems: "center",
+ justifyContent: "center",
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ },
+});
diff --git a/components/ui/SideMenu.tsx b/components/ui/SideMenu.tsx
new file mode 100644
index 0000000..51558e9
--- /dev/null
+++ b/components/ui/SideMenu.tsx
@@ -0,0 +1,568 @@
+import React, { useState, useRef, useEffect } from "react";
+import { View, StyleSheet, Pressable, Dimensions, Animated, Easing, ScrollView, Alert } from "react-native";
+import * as Clipboard from "expo-clipboard";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import * as Haptics from 'expo-haptics';
+import { Image } from "expo-image";
+import { Text } from "~/components/ui/text";
+import { useAuth } from "~/lib/auth-provider";
+import { useAppSettings } from "~/lib/AppSettingsContext";
+import { useToast } from "~/lib/toast-provider";
+import { theme } from "~/lib/theme";
+import useHiveAccount from "~/lib/hooks/useHiveAccount";
+import { EditProfileModal } from "~/components/Profile/EditProfileModal";
+
+const { width: SCREEN_WIDTH } = Dimensions.get("window");
+const DRAWER_WIDTH = SCREEN_WIDTH * 0.85;
+
+interface SideMenuProps {
+ isVisible: boolean;
+ onClose: () => void;
+}
+
+type MenuView = "settings" | "accounts";
+
+export function SideMenu({ isVisible, onClose }: SideMenuProps) {
+ const router = useRouter();
+ const { username, logout, storedUsers, deleteStoredUser, refreshUserRelationships } = useAuth();
+ const { settings, updateSettings } = useAppSettings();
+ const { showToast } = useToast();
+ const { hiveAccount } = useHiveAccount(username || "");
+
+ const [currentView, setCurrentView] = useState("settings");
+ const [isEditProfileVisible, setIsEditProfileVisible] = useState(false);
+
+ // Animation values
+ const slideAnim = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+ const viewTransitionAnim = useRef(new Animated.Value(0)).current; // 0 for settings, 1 for accounts
+
+ useEffect(() => {
+ if (isVisible) {
+ Animated.parallel([
+ Animated.timing(slideAnim, {
+ toValue: 0,
+ duration: 300,
+ easing: Easing.out(Easing.ease),
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: 300,
+ useNativeDriver: true,
+ })
+ ]).start();
+ } else {
+ Animated.parallel([
+ Animated.timing(slideAnim, {
+ toValue: -DRAWER_WIDTH,
+ duration: 250,
+ easing: Easing.in(Easing.ease),
+ useNativeDriver: true,
+ }),
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 250,
+ useNativeDriver: true,
+ })
+ ]).start(() => {
+ setCurrentView("settings");
+ viewTransitionAnim.setValue(0);
+ });
+ }
+ }, [isVisible]);
+
+ const transitionTo = (view: MenuView) => {
+ const toValue = view === "accounts" ? 1 : 0;
+ setCurrentView(view);
+ Animated.timing(viewTransitionAnim, {
+ toValue,
+ duration: 300,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }).start();
+ };
+
+ const handleLogout = async () => {
+ onClose();
+ await logout();
+ router.replace("/");
+ };
+
+ const handleRemoveAccount = () => {
+ if (!username || username === 'SPECTATOR') return;
+
+ Alert.alert(
+ "Remove Account",
+ `Are you sure you want to remove @${username} from this device? You will need your posting key to log in again.`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Remove",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ onClose();
+ await deleteStoredUser(username);
+ router.replace("/");
+ } catch (error) {
+ console.error("Error deleting account:", error);
+ showToast("Failed to remove account.", "error");
+ }
+ }
+ }
+ ]
+ );
+ };
+
+ const copyToClipboard = async (text: string, label: string) => {
+ try {
+ await Clipboard.setStringAsync(text);
+ await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ showToast(`${label} copied!`, "success");
+ } catch (error) {
+ console.error(`Error copying ${label}:`, error);
+ showToast(`Failed to copy ${label}.`, "error");
+ }
+ };
+
+ // Helper for rendering account avatar
+ const renderAvatar = (size = 40) => {
+ const profileImage = hiveAccount?.metadata?.profile?.profile_image;
+ const hiveAvatarUrl = `https://images.hive.blog/u/${username}/avatar/small`;
+ return (
+
+ );
+ };
+
+ const renderCard = (items: { title: string, icon: any, value?: string, onPress: () => void, disabled?: boolean }[]) => (
+
+ {items.map((item, index) => (
+
+
+
+
+ {item.title}
+
+
+ {item.value && {item.value} }
+ {!item.disabled && }
+
+
+ {index < items.length - 1 && }
+
+ ))}
+
+ );
+
+ const settingsItems = {
+ service: [
+ { title: "Scan", icon: "qr-code-outline" as const, onPress: () => { } },
+ { title: "Multi-Device Login", icon: "phone-portrait-outline" as const, disabled: true, onPress: () => { } },
+ { title: "Lucky Drop History", icon: "gift-outline" as const, disabled: true, onPress: () => { } },
+ { title: "Viewing History", icon: "time-outline" as const, onPress: () => { } },
+ { title: "Bookmarks", icon: "bookmark-outline" as const, onPress: () => { } },
+ { title: "Mute List", icon: "volume-mute-outline" as const, onPress: () => { } },
+ { title: "Push Notifications", icon: "notifications-outline" as const, onPress: () => { } },
+ ],
+ appearance: [
+ { title: "Theme", icon: "color-palette-outline" as const, value: "System", onPress: () => { } },
+ { title: "Language", icon: "language-outline" as const, value: "System", onPress: () => { } },
+ {
+ title: settings.useVoteSlider ? "Vote: Slider" : "Vote: Preset Buttons",
+ icon: settings.useVoteSlider ? "options-outline" as const : "grid-outline" as const,
+ onPress: () => { updateSettings({ useVoteSlider: !settings.useVoteSlider }); }
+ },
+ {
+ title: `Stance: ${settings.stance.charAt(0).toUpperCase() + settings.stance.slice(1)}`,
+ icon: "body-outline" as const,
+ onPress: () => { updateSettings({ stance: settings.stance === 'regular' ? 'goofy' : 'regular' }); }
+ },
+ { title: "Feeds", icon: "list-outline" as const, value: "System", onPress: () => { } },
+ ],
+ about: [
+ { title: "About Skatehive", icon: "information-circle-outline" as const, onPress: () => { onClose(); router.push("/about"); } },
+ ]
+ };
+
+ const socialSlots = [
+ { title: "X", icon: "logo-twitter" as const, value: "Coming Soon", disabled: true },
+ { title: "Farcaster", icon: "cube-outline" as const, value: "Coming Soon", disabled: true },
+ { title: "Lens", icon: "leaf-outline" as const, value: "Coming Soon", disabled: true },
+ { title: "Bluesky", icon: "cloud-outline" as const, value: "Coming Soon", disabled: true },
+ { title: "Google", icon: "logo-google" as const, value: "Coming Soon", disabled: true },
+ { title: "Telegram", icon: "paper-plane-outline" as const, value: "Coming Soon", disabled: true },
+ { title: "Email", icon: "mail-outline" as const, value: "Coming Soon", disabled: true },
+ ];
+
+ const renderSettingsView = () => (
+
+
+
+ Settings
+
+
+
+
+
+
+ {/* Account Entry Card */}
+ transitionTo("accounts")}>
+
+ {renderAvatar(50)}
+
+ {hiveAccount?.metadata?.profile?.name || username}
+ UID:{hiveAccount?.id || "---"}
+
+
+
+
+
+ { }}>
+
+
+
+ Wallets
+
+
+
+
+
+ {/* service section hidden per user request */}
+ {/* Service
+ {renderCard(settingsItems.service)} */}
+
+ Appearance
+ {renderCard(settingsItems.appearance)}
+
+ About
+ {renderCard(settingsItems.about)}
+
+
+
+
+ );
+
+ const renderAccountsView = () => (
+
+
+ transitionTo("settings")} style={styles.backButton}>
+
+
+ Accounts
+ setIsEditProfileVisible(true)}
+ style={styles.editButton}
+ >
+ Edit
+
+
+
+
+
+ {renderAvatar(80)}
+ copyToClipboard(username || "", "Username")}
+ style={styles.accountDetailInfo}
+ >
+ {hiveAccount?.metadata?.profile?.name || username}
+ UID:{hiveAccount?.id || "---"}
+
+
+
+
+ {/* Active Session */}
+
+
+
+
+ Hive
+ Active Session
+
+
+ @{username}
+
+
+ {/* Other Stored Sessions */}
+ {storedUsers.filter(u => u.username !== username && u.username !== "SPECTATOR").map((user, idx) => (
+
+
+
+
+
+ @{user.username}
+
+ Stored
+
+
+ ))}
+
+
+ { onClose(); router.push("/login"); }}>
+ Add Hive Account
+
+
+
+
+ {socialSlots.map((slot, idx) => (
+
+
+
+
+ {slot.title}
+
+ {slot.value}
+
+
+ ))}
+
+
+
+ Log Out
+
+
+
+ Remove from Device
+
+
+
+
+
+
+ );
+
+ if (!isVisible && slideAnim.addListener === undefined) return null;
+
+ const translateX = viewTransitionAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, -DRAWER_WIDTH],
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ {renderSettingsView()}
+ {renderAccountsView()}
+
+
+
+
+ setIsEditProfileVisible(false)}
+ currentProfile={hiveAccount?.metadata?.profile || {}}
+ onSaved={() => {
+ refreshUserRelationships();
+ }}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ zIndex: 100,
+ },
+ drawer: {
+ position: "absolute",
+ top: 0,
+ bottom: 0,
+ left: 0,
+ width: DRAWER_WIDTH,
+ backgroundColor: theme.colors.background,
+ zIndex: 101,
+ borderRightWidth: 1,
+ borderRightColor: theme.colors.border,
+ overflow: "hidden", // Prevent multi-view bleed
+ },
+ safeArea: {
+ flex: 1,
+ },
+ multiViewContainer: {
+ flex: 1,
+ flexDirection: "row",
+ width: DRAWER_WIDTH * 2,
+ },
+ viewContent: {
+ width: DRAWER_WIDTH,
+ flex: 1,
+ },
+ viewHeader: {
+ height: 60,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: theme.spacing.md,
+ },
+ headerTitle: {
+ fontSize: theme.fontSizes.lg,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ },
+ closeButton: {
+ padding: theme.spacing.sm,
+ },
+ backButton: {
+ padding: theme.spacing.sm,
+ },
+ editButton: {
+ padding: theme.spacing.sm,
+ },
+ editButtonText: {
+ color: theme.colors.primary,
+ fontFamily: theme.fonts.bold,
+ fontSize: theme.fontSizes.md,
+ },
+ scrollContent: {
+ flex: 1,
+ paddingHorizontal: theme.spacing.md,
+ },
+ accountCard: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: theme.colors.card,
+ padding: theme.spacing.md,
+ borderRadius: theme.borderRadius.lg,
+ justifyContent: "space-between",
+ marginTop: theme.spacing.sm,
+ marginBottom: theme.spacing.md,
+ },
+ accountCardLeft: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing.md,
+ },
+ accountInfo: {
+ gap: 2,
+ },
+ displayName: {
+ fontSize: theme.fontSizes.md,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ },
+ uid: {
+ fontSize: theme.fontSizes.xs,
+ color: theme.colors.muted,
+ },
+ card: {
+ backgroundColor: theme.colors.card,
+ borderRadius: theme.borderRadius.lg,
+ overflow: "hidden",
+ },
+ groupLabel: {
+ fontSize: theme.fontSizes.xs,
+ color: theme.colors.muted,
+ fontFamily: theme.fonts.bold,
+ marginTop: theme.spacing.lg,
+ marginBottom: theme.spacing.xs,
+ marginLeft: theme.spacing.sm,
+ textTransform: "uppercase",
+ },
+ menuItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ padding: theme.spacing.md,
+ justifyContent: "space-between",
+ },
+ menuItemLeft: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing.md,
+ },
+ menuItemRight: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing.xs,
+ },
+ menuItemText: {
+ fontSize: theme.fontSizes.md,
+ color: theme.colors.text,
+ fontFamily: theme.fonts.regular,
+ },
+ sessionStatus: {
+ fontSize: 10,
+ color: theme.colors.primary,
+ fontFamily: theme.fonts.bold,
+ textTransform: "uppercase",
+ },
+ menuItemTextSecondary: {
+ fontSize: theme.fontSizes.md,
+ color: theme.colors.text,
+ fontFamily: theme.fonts.bold,
+ },
+ menuItemValue: {
+ fontSize: theme.fontSizes.sm,
+ color: theme.colors.muted,
+ fontFamily: theme.fonts.regular,
+ },
+ divider: {
+ height: 1,
+ backgroundColor: theme.colors.border,
+ marginLeft: 54,
+ },
+ accountsHeader: {
+ alignItems: "center",
+ paddingVertical: theme.spacing.xl,
+ gap: theme.spacing.sm,
+ },
+ accountDetailInfo: {
+ alignItems: "center",
+ },
+ accountsDisplayName: {
+ fontSize: theme.fontSizes.xl,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.text,
+ },
+ accountsUid: {
+ fontSize: theme.fontSizes.sm,
+ color: theme.colors.muted,
+ },
+ footerActions: {
+ marginTop: theme.spacing.xxxl,
+ gap: theme.spacing.lg,
+ alignItems: "center",
+ },
+ logoutButton: {
+ width: "100%",
+ backgroundColor: theme.colors.card,
+ paddingVertical: theme.spacing.md,
+ borderRadius: theme.borderRadius.full,
+ alignItems: "center",
+ },
+ logoutButtonText: {
+ color: theme.colors.text,
+ fontFamily: theme.fonts.bold,
+ fontSize: theme.fontSizes.lg,
+ },
+ removeButton: {
+ padding: theme.spacing.sm,
+ },
+ removeButtonText: {
+ color: theme.colors.danger,
+ fontFamily: theme.fonts.bold,
+ fontSize: theme.fontSizes.md,
+ },
+});
diff --git a/components/ui/VotePresetButtons.tsx b/components/ui/VotePresetButtons.tsx
new file mode 100644
index 0000000..8a8639d
--- /dev/null
+++ b/components/ui/VotePresetButtons.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { View, Pressable, StyleSheet } from 'react-native';
+import { Text } from '~/components/ui/text';
+import { theme } from '~/lib/theme';
+
+const PRESETS = [5, 10, 25, 50, 75, 100];
+
+interface VotePresetButtonsProps {
+ onSelect: (weight: number) => void;
+ disabled?: boolean;
+}
+
+export function VotePresetButtons({ onSelect, disabled = false }: VotePresetButtonsProps) {
+ return (
+
+ {PRESETS.map((weight) => (
+ [
+ styles.button,
+ pressed && styles.buttonPressed,
+ disabled && styles.buttonDisabled,
+ ]}
+ onPress={() => onSelect(weight)}
+ disabled={disabled}
+ >
+ {weight}%
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ flex: 1,
+ gap: 4,
+ },
+ button: {
+ flex: 1,
+ paddingVertical: 8,
+ paddingHorizontal: 4,
+ borderRadius: 8,
+ backgroundColor: 'rgba(50, 205, 50, 0.12)',
+ borderWidth: 1,
+ borderColor: 'rgba(50, 205, 50, 0.3)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ buttonPressed: {
+ backgroundColor: 'rgba(50, 205, 50, 0.3)',
+ borderColor: theme.colors.primary,
+ },
+ buttonDisabled: {
+ opacity: 0.4,
+ },
+ buttonText: {
+ fontSize: 12,
+ fontFamily: theme.fonts.bold,
+ color: theme.colors.primary,
+ },
+});
diff --git a/components/ui/loading-effects/MatrixRain.tsx b/components/ui/loading-effects/MatrixRain.tsx
index 9c82580..2df5af8 100644
--- a/components/ui/loading-effects/MatrixRain.tsx
+++ b/components/ui/loading-effects/MatrixRain.tsx
@@ -33,14 +33,21 @@ interface MatrixRainProps {
speed?: number; // Animation speed multiplier
color?: string; // Override default color
opacity?: number; // Override default opacity
+ containerWidth?: number;
+ containerHeight?: number;
}
export function MatrixRain({
intensity = 1,
speed = 1,
color,
- opacity: customOpacity
+ opacity: customOpacity,
+ containerWidth: propWidth,
+ containerHeight: propHeight
}: MatrixRainProps = {}) {
+ const finalWidth = propWidth || width;
+ const finalHeight = propHeight || height;
+
const rainDrops = useRef([]);
const [animationReady, setAnimationReady] = useState(false);
const maxDrops = Math.floor(MAX_DROPS * intensity);
@@ -53,8 +60,9 @@ export function MatrixRain({
// Initialize drops with full-screen distribution to simulate already-running animation
if (rainDrops.current.length === 0) {
// Ensure drops cover the entire width evenly
- const columnPositions = Array(NUM_COLUMNS).fill(0)
- .map((_, i) => i * (width / NUM_COLUMNS))
+ const numCols = Math.floor(finalWidth / (BASE_FONT_SIZE * COLUMN_SPACING));
+ const columnPositions = Array(numCols).fill(0)
+ .map((_, i) => i * (finalWidth / numCols))
// Add some randomness to column positions for more natural look
.map(pos => pos + (Math.random() * BASE_FONT_SIZE * 0.5));
@@ -65,7 +73,7 @@ export function MatrixRain({
// Distribute drops randomly throughout the entire screen height
// This simulates that the animation has already been running
- const initialPosition = Math.random() * (height + fontSize * dropLength * 2) - fontSize * dropLength;
+ const initialPosition = Math.random() * (finalHeight + fontSize * dropLength * 2) - fontSize * dropLength;
return {
// Assign position from columns, then add randomness for repeated positions
@@ -107,7 +115,7 @@ export function MatrixRain({
Animated.sequence([
Animated.delay(drop.startDelay),
Animated.timing(drop.y, {
- toValue: height + drop.fontSize * drop.length,
+ toValue: finalHeight + drop.fontSize * drop.length,
duration: cycleTime,
useNativeDriver: true,
}),
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 9e6b55a..1004514 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Animated, Dimensions, TouchableOpacity, StyleSheet } from 'react-native';
import { Text } from './text';
import { theme } from '../../lib/theme';
+import { MatrixRain } from '~/components/ui/loading-effects/MatrixRain';
const { width } = Dimensions.get('window');
@@ -81,7 +82,11 @@ export function Toast({ message, type = 'error', onHide }: ToastProps) {
}
]}
>
-
+ {type === 'error' && }
+
{message}
@@ -104,7 +109,10 @@ const styles = StyleSheet.create({
elevation: 5,
},
errorBackground: {
- backgroundColor: 'rgba(239, 68, 68, 0.9)', // red-500/90
+ backgroundColor: '#000000',
+ borderWidth: 1,
+ borderColor: '#FF3B30',
+ overflow: 'hidden',
},
successBackground: {
backgroundColor: 'rgba(34, 197, 94, 0.9)', // green-500/90
@@ -120,4 +128,11 @@ const styles = StyleSheet.create({
fontFamily: theme.fonts.regular,
fontSize: 16,
},
+ errorMessageText: {
+ color: theme.colors.primary,
+ fontFamily: theme.fonts.bold,
+ textShadowColor: 'rgba(0, 0, 0, 0.8)',
+ textShadowOffset: { width: 1, height: 1 },
+ textShadowRadius: 2,
+ },
});
\ No newline at end of file
diff --git a/debug-vitim.ts b/debug-vitim.ts
new file mode 100644
index 0000000..e036de0
--- /dev/null
+++ b/debug-vitim.ts
@@ -0,0 +1,59 @@
+import { Client } from '@hiveio/dhive';
+import { MarkdownProcessor } from './lib/markdown/MarkdownProcessor';
+
+const HiveClient = new Client([
+ "https://api.deathwing.me",
+ "https://api.hive.blog",
+]);
+
+async function debugVitimSnaps() {
+ const username = 'vitimribeiro';
+ console.log(`Fetching last posts/comments for @${username}...`);
+
+ try {
+ // get_account_posts with sort: 'comments' gets his replies (snaps)
+ const posts = await HiveClient.call('bridge', 'get_account_posts', {
+ account: username,
+ sort: 'comments',
+ limit: 20
+ });
+
+ console.log(`Found ${posts.length} comments. Filtering for snaps...`);
+
+ const snaps = posts.filter((p: any) => {
+ try {
+ const meta = typeof p.json_metadata === 'string' ? JSON.parse(p.json_metadata) : p.json_metadata;
+ return meta.tags && meta.tags.includes('hive-173115');
+ } catch {
+ return false;
+ }
+ }).slice(0, 5);
+
+ console.log(`Analyzing last ${snaps.length} snaps:\n`);
+
+ for (const snap of snaps) {
+ console.log('==========================================');
+ console.log(`PERMLINK: ${snap.permlink}`);
+ console.log(`CREATED: ${snap.created}`);
+ console.log('--- RAW BODY ---');
+ console.log(snap.body);
+
+ const processed = MarkdownProcessor.process(snap.body);
+ console.log('\n--- PROCESSED CONTENT ---');
+ console.log(processed.contentWithPlaceholders);
+
+ const parts = processed.contentWithPlaceholders.split(/(\[\[(?:YOUTUBE|VIMEO|ODYSEE|THREESPEAK|IPFSVIDEO|INSTAGRAM|ZORACOIN|SNAPSHOT|IMAGE):[^\]]+\]\])/g);
+ console.log('\n--- SPLIT PARTS ---');
+ parts.forEach((part, i) => {
+ if (part.trim()) {
+ console.log(`Part ${i}: ${part.trim().substring(0, 100)}${part.length > 100 ? '...' : ''}`);
+ }
+ });
+ console.log('==========================================\n');
+ }
+ } catch (error) {
+ console.error('Error debugging snaps:', error);
+ }
+}
+
+debugVitimSnaps();
diff --git a/env.d.ts b/env.d.ts
deleted file mode 100644
index 0a9fbb4..0000000
--- a/env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-declare module '@env' {
- export const API_BASE_URL: string;
- export const LOADING_EFFECT: string;
- export const MODERATOR_PUBLIC_KEY: string;
- // ...other env variables
-}
\ No newline at end of file
diff --git a/lib/AppSettingsContext.tsx b/lib/AppSettingsContext.tsx
new file mode 100644
index 0000000..15f8a0b
--- /dev/null
+++ b/lib/AppSettingsContext.tsx
@@ -0,0 +1,61 @@
+import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
+import * as SecureStore from 'expo-secure-store';
+
+const SETTINGS_KEY = 'app_settings';
+
+export interface AppSettings {
+ useVoteSlider: boolean; // true = slider, false = preset buttons
+ stance: 'regular' | 'goofy';
+}
+
+const DEFAULT_SETTINGS: AppSettings = {
+ useVoteSlider: true,
+ stance: 'regular',
+};
+
+interface AppSettingsContextType {
+ settings: AppSettings;
+ updateSettings: (updates: Partial) => void;
+}
+
+const AppSettingsContext = createContext(undefined);
+
+export const AppSettingsProvider = ({ children }: { children: ReactNode }) => {
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+
+ // Load settings on mount
+ useEffect(() => {
+ (async () => {
+ try {
+ const stored = await SecureStore.getItemAsync(SETTINGS_KEY);
+ if (stored) {
+ setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(stored) });
+ }
+ } catch (error) {
+ console.error('Error loading settings:', error);
+ }
+ })();
+ }, []);
+
+ const updateSettings = (updates: Partial) => {
+ setSettings(prev => {
+ const next = { ...prev, ...updates };
+ SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(next)).catch(console.error);
+ return next;
+ });
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAppSettings = () => {
+ const context = useContext(AppSettingsContext);
+ if (context === undefined) {
+ throw new Error('useAppSettings must be used within an AppSettingsProvider');
+ }
+ return context;
+};
diff --git a/lib/FeedFilterContext.tsx b/lib/FeedFilterContext.tsx
new file mode 100644
index 0000000..4434306
--- /dev/null
+++ b/lib/FeedFilterContext.tsx
@@ -0,0 +1,28 @@
+import React, { createContext, useContext, useState } from 'react';
+
+export type FeedFilterType = 'Recent' | 'Following' | 'Curated' | 'Trending';
+
+interface FeedFilterContextType {
+ filter: FeedFilterType;
+ setFilter: (filter: FeedFilterType) => void;
+}
+
+const FeedFilterContext = createContext(undefined);
+
+export function FeedFilterProvider({ children }: { children: React.ReactNode }) {
+ const [filter, setFilter] = useState('Recent');
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useFeedFilter() {
+ const context = useContext(FeedFilterContext);
+ if (context === undefined) {
+ throw new Error('useFeedFilter must be used within a FeedFilterProvider');
+ }
+ return context;
+}
diff --git a/lib/ScrollLockContext.tsx b/lib/ScrollLockContext.tsx
new file mode 100644
index 0000000..743d4e9
--- /dev/null
+++ b/lib/ScrollLockContext.tsx
@@ -0,0 +1,26 @@
+import React, { createContext, useState, useContext, ReactNode } from 'react';
+
+interface ScrollLockContextType {
+ isScrollLocked: boolean;
+ setScrollLocked: (locked: boolean) => void;
+}
+
+const ScrollLockContext = createContext(undefined);
+
+export const ScrollLockProvider = ({ children }: { children: ReactNode }) => {
+ const [isScrollLocked, setScrollLocked] = useState(false);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useScrollLock = () => {
+ const context = useContext(ScrollLockContext);
+ if (context === undefined) {
+ throw new Error('useScrollLock must be used within a ScrollLockProvider');
+ }
+ return context;
+};
diff --git a/lib/api.ts b/lib/api.ts
index 418916b..b907544 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -1,4 +1,7 @@
-import { API_BASE_URL } from './constants';
+import {
+ API_BASE_URL,
+} from './constants';
+import { getUserRelationshipList } from './hive-utils';
import type { Post } from './types';
interface ApiResponse {
@@ -82,4 +85,66 @@ export async function getRewards(username: string) {
console.error('Error fetching rewards:', error);
return null;
}
+}
+
+/**
+ * Fetches user's following list (usernames) from Skatehive API
+ * Falls back to RPC (bridge API) if the Skatehive API fails
+ */
+export async function getFollowingList(username: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/relationships/${username}/following`);
+ const data: ApiResponse = await response.json();
+ if (data.success && Array.isArray(data.data)) return data.data;
+ } catch (error) {
+ console.warn('[Relationships] API failed for following, falling back to RPC:', error);
+ }
+ // Fallback to RPC
+ return getUserRelationshipList(username, 'blog');
+}
+
+/**
+ * Fetches user's followers list (usernames) from Skatehive API
+ * Falls back to RPC (bridge API) if the Skatehive API fails
+ */
+export async function getFollowersList(username: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/relationships/${username}/followers`);
+ const data: ApiResponse = await response.json();
+ if (data.success && Array.isArray(data.data)) return data.data;
+ } catch (error) {
+ console.warn('[Relationships] API failed for followers, falling back to RPC:', error);
+ }
+ // Fallback to RPC
+ return getUserRelationshipList(username, 'blog');
+}
+
+/**
+ * Fetches user's muted list (usernames) from Skatehive API with fallback to bridge API
+ */
+export async function getMutedList(username: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/relationships/${username}/muted`);
+ const data: ApiResponse = await response.json();
+ if (data.success && Array.isArray(data.data)) return data.data;
+ } catch (error) {
+ console.warn('[Muted] API failed, falling back to RPC:', error);
+ }
+ // Fallback to bridge API
+ return getUserRelationshipList(username, 'ignore');
+}
+
+/**
+ * Fetches user's blacklisted list (usernames) from Skatehive API
+ * Note: No RPC fallback since bridge.get_following doesn't support 'blacklist'
+ */
+export async function getBlacklistedList(username: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}/relationships/${username}/blacklisted`);
+ const data: ApiResponse = await response.json();
+ if (data.success && Array.isArray(data.data)) return data.data;
+ } catch (error) {
+ console.warn('[Blacklisted] API failed, no RPC fallback available:', error);
+ }
+ return [];
}
\ No newline at end of file
diff --git a/lib/auth-provider.tsx b/lib/auth-provider.tsx
index 7a81b36..eb0f007 100644
--- a/lib/auth-provider.tsx
+++ b/lib/auth-provider.tsx
@@ -1,5 +1,6 @@
import * as SecureStore from 'expo-secure-store';
-import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import React, { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
import { STORED_USERS_KEY } from './constants';
import {
AccountNotFoundError,
@@ -27,6 +28,10 @@ import {
getUserRelationshipList,
setUserRelationship
} from './hive-utils';
+import { useAppSettings } from './AppSettingsContext';
+import { getFollowingList, getFollowersList, getMutedList, getBlacklistedList } from './api';
+
+const SESSION_KEY = 'current_auth_session';
// ============================================================================
// APPLE REVIEW TEST ACCOUNT CONFIGURATION
@@ -93,6 +98,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [mutedList, setMutedList] = useState([]);
const [blacklistedList, setBlacklistedList] = useState([]);
const inactivityTimer = useRef | null>(null);
+ const { settings } = useAppSettings();
// Delete a single stored user and update state
const removeStoredUser = async (usernameToRemove: string) => {
@@ -113,8 +119,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
- // Inactivity timeout (5 minutes)
- const INACTIVITY_TIMEOUT = 60 * 60 * 1000;
+ // Inactivity timeout based on settings
+ const INACTIVITY_TIMEOUT = settings.sessionDuration * 60 * 1000;
useEffect(() => {
loadStoredUsers();
@@ -129,12 +135,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} else {
clearInactivityTimer();
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [session]);
+ // Handle Session persistence settings change
+ useEffect(() => {
+ if (settings.sessionDuration === 0) {
+ // If Auto-lock is enabled, clear any persistent session from storage
+ // The current session will stay in memory until the app is closed
+ SecureStore.deleteItemAsync(SESSION_KEY).catch(console.error);
+ }
+ }, [settings.sessionDuration]);
+
const resetInactivityTimer = () => {
// Only reset timer if user is authenticated and has a session
- if (!session || !isAuthenticated) return;
+ if (!session || !isAuthenticated || settings.sessionDuration === 0) return;
clearInactivityTimer();
inactivityTimer.current = setTimeout(() => {
@@ -154,29 +168,64 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
// Load user relationship lists (following, muted, blacklisted)
- const refreshUserRelationships = async () => {
- if (!username || username === 'SPECTATOR') {
+ const refreshUserRelationships = useCallback(async (explicitUsername?: string) => {
+ const targetUser = explicitUsername || username;
+
+ if (!targetUser || targetUser === 'SPECTATOR') {
setFollowingList([]);
setMutedList([]);
setBlacklistedList([]);
return;
}
+ // 1. Instantly load from local disk cache to prevent UI flashing
+ try {
+ const cacheKey = `skatehive_relationships_${targetUser}`;
+ const cachedDataStr = await AsyncStorage.getItem(cacheKey);
+
+ if (cachedDataStr) {
+ const cachedData = JSON.parse(cachedDataStr);
+ if (cachedData.following) setFollowingList(cachedData.following);
+ if (cachedData.muted) setMutedList(cachedData.muted);
+ if (cachedData.blacklisted) setBlacklistedList(cachedData.blacklisted);
+ console.log(`[Auth] Loaded cached relationships for @${targetUser}`);
+ }
+ } catch (cacheError) {
+ console.warn(`[Auth] Failed to load relationship cache for @${targetUser}:`, cacheError);
+ }
+
+ // 2. Fetch fresh data silently in the background
try {
- const [following, muted, blacklisted] = await Promise.all([
- getUserRelationshipList(username, 'blog'),
- getUserRelationshipList(username, 'ignore'),
- getUserRelationshipList(username, 'blacklist'),
+ const [following, muted, blacklisted, followers] = await Promise.all([
+ getFollowingList(targetUser),
+ getMutedList(targetUser),
+ getBlacklistedList(targetUser),
+ getFollowersList(targetUser),
]);
+ // Update React state
setFollowingList(following);
setMutedList(muted);
setBlacklistedList(blacklisted);
+
+ // 3. Save the fresh data back to the disk cache
+ try {
+ const cacheKey = `skatehive_relationships_${targetUser}`;
+ const cacheDataToSave = JSON.stringify({ following, muted, blacklisted });
+ await AsyncStorage.setItem(cacheKey, cacheDataToSave);
+ } catch (saveError) {
+ console.warn(`[Auth] Failed to save relationship cache for @${targetUser}:`, saveError);
+ }
+
+ console.log(`[Auth] User relationships refreshed & cached for @${targetUser}:`);
+ console.log(` - Following: ${following.length} users (${following.slice(0, 5).join(', ')}...)`);
+ console.log(` - Muted: ${muted.length} users`);
+ console.log(` - Blacklisted: ${blacklisted.length} users`);
} catch (error) {
- console.error('Error loading user relationships:', error);
+ console.error(`[Auth] Error refreshing relationships for @${targetUser}:`, error);
// Don't throw error, just log it to avoid breaking the app
}
- };
+ }, [username]);
// Update user relationship and refresh the lists
const updateUserRelationship = async (
@@ -225,6 +274,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
let users: StoredUser[] = [];
if (keys) {
users = JSON.parse(keys);
+ // Clean up invalid entries on load
+ const cleanedUsers = users.filter(u => u.username && u.username.trim() !== "");
+ if (cleanedUsers.length !== users.length) {
+ users = cleanedUsers;
+ await SecureStore.setItemAsync(STORED_USERS_KEY, JSON.stringify(users));
+ }
}
setStoredUsers(users);
} catch (error) {
@@ -235,7 +290,46 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Check if a user is already logged in (restore session)
const checkCurrentUser = async () => {
try {
- // Do not auto-login: always require full login for decrypted key
+ // Robust check: Verify session duration from storage to avoid race conditions
+ // with AppSettingsContext loading.
+ const storedSettingsStr = await SecureStore.getItemAsync('app_settings');
+ let isAutoLock = false;
+ if (storedSettingsStr) {
+ const storedSettings = JSON.parse(storedSettingsStr);
+ if (storedSettings.sessionDuration === 0) {
+ isAutoLock = true;
+ }
+ }
+
+ if (isAutoLock) {
+ // If Auto-lock is enabled, we never restore from SecureStore
+ await SecureStore.deleteItemAsync(SESSION_KEY);
+ setUsername(null);
+ setIsAuthenticated(false);
+ setSession(null);
+ return;
+ }
+
+ const storedSession = await SecureStore.getItemAsync(SESSION_KEY);
+ if (storedSession) {
+ const parsed: AuthSession & { expiryAt: number } = JSON.parse(storedSession);
+
+ // Check if session has expired
+ if (parsed.expiryAt > Date.now()) {
+ setUsername(parsed.username);
+ setSession(parsed);
+ setIsAuthenticated(true);
+
+ // Refresh relationships in background
+ refreshUserRelationships(parsed.username);
+ return;
+ } else {
+ // Session expired, clear it
+ await SecureStore.deleteItemAsync(SESSION_KEY);
+ }
+ }
+
+ // No valid session found
setUsername(null);
setIsAuthenticated(false);
setSession(null);
@@ -248,6 +342,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// Update stored users list in SecureStore
const updateStoredUsers = async (user: StoredUser) => {
+ if (!user.username || user.username === 'SPECTATOR') return;
try {
let users = [...storedUsers];
users = users.filter(u => u.username !== user.username);
@@ -333,12 +428,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
createdAt: Date.now(),
};
await updateStoredUsers(user);
+ const authSession: AuthSession = {
+ username: normalizedUsername,
+ decryptedKey: postingKey,
+ loginTime: Date.now()
+ };
+
+ // Store session for persistence (skip if duration is 0 / "Auto")
+ if (settings.sessionDuration > 0) {
+ const expiryAt = Date.now() + (settings.sessionDuration * 60 * 1000);
+ await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...authSession, expiryAt }));
+ }
+
setUsername(normalizedUsername);
setIsAuthenticated(true);
- setSession({ username: normalizedUsername, decryptedKey: postingKey, loginTime: Date.now() });
+ setSession(authSession);
// Load user relationships after successful login
- setTimeout(() => refreshUserRelationships(), 100);
+ refreshUserRelationships(normalizedUsername);
} catch (error) {
if (
error instanceof InvalidKeyFormatError ||
@@ -392,13 +499,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await deleteEncryptedKey(selectedUsername);
throw new AuthError('Stored credentials are incompatible. Please log in again.');
}
+ const authSession: AuthSession = {
+ username: selectedUsername,
+ decryptedKey,
+ loginTime: Date.now()
+ };
+
+ // Store session for persistence (skip if duration is 0 / "Auto")
+ if (settings.sessionDuration > 0) {
+ const expiryAt = Date.now() + (settings.sessionDuration * 60 * 1000);
+ await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...authSession, expiryAt }));
+ }
+
setUsername(selectedUsername);
setIsAuthenticated(true);
- setSession({ username: selectedUsername, decryptedKey, loginTime: Date.now() });
+ setSession(authSession);
await updateStoredUsers({ username: selectedUsername, method: encryptedKey.method, createdAt: encryptedKey.createdAt });
// Load user relationships after successful login
- setTimeout(() => refreshUserRelationships(), 100);
+ refreshUserRelationships(selectedUsername);
} catch (error) {
if (
error instanceof InvalidKeyFormatError ||
@@ -424,6 +543,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setFollowingList([]);
setMutedList([]);
setBlacklistedList([]);
+ await SecureStore.deleteItemAsync(SESSION_KEY);
await SecureStore.deleteItemAsync('lastLoggedInUser');
} catch (error) {
console.error('Error during logout:', error);
@@ -451,6 +571,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await deleteEncryptedKey(user.username);
}
await SecureStore.deleteItemAsync(STORED_USERS_KEY);
+ await SecureStore.deleteItemAsync(SESSION_KEY);
await SecureStore.deleteItemAsync('lastLoggedInUser');
setStoredUsers([]);
setSession(null);
diff --git a/lib/constants.ts b/lib/constants.ts
index 05143d2..841efc2 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -1,7 +1,9 @@
-export const APP_NAME="SkateHive";
+import { API_BASE_URL as ENV_API_BASE_URL, LEADERBOARD_API_URL as ENV_LEADERBOARD_API_URL } from '@env';
+
+export const APP_NAME="Skatehive";
export const STORED_USERS_KEY = 'myc_users';
-export const API_BASE_URL = 'https://api.skatehive.app/api/v1';
-export const LEADERBOARD_API_URL = 'https://api.skatehive.app/api/v2/leaderboard';
+export const API_BASE_URL = ENV_API_BASE_URL || 'https://api.skatehive.app/api/v1';
+export const LEADERBOARD_API_URL = ENV_LEADERBOARD_API_URL || 'https://api.skatehive.app/api/v2/leaderboard';
export const NAV_THEME = {
light: {
diff --git a/lib/hive-utils.ts b/lib/hive-utils.ts
index 9391325..c7c89cb 100644
--- a/lib/hive-utils.ts
+++ b/lib/hive-utils.ts
@@ -284,6 +284,32 @@ export async function getContentReplies({
return HiveClient.database.call('get_content_replies', [author, permlink]);
}
+/**
+ * Get discussions (posts) by filter and tag
+ */
+export async function getDiscussions(
+ type: 'created' | 'trending' | 'hot' | 'feed',
+ query: {
+ tag?: string;
+ limit?: number;
+ start_author?: string;
+ start_permlink?: string;
+ }
+): Promise {
+ const params: any = {
+ limit: query.limit || 10,
+ tag: query.tag || COMMUNITY_TAG,
+ };
+
+ if (query.start_author && query.start_permlink) {
+ params.start_author = query.start_author;
+ params.start_permlink = query.start_permlink;
+ }
+
+ // get_discussions_by_feed requires account name in 'tag' field
+ return HiveClient.database.call(`get_discussions_by_${type}`, [params]);
+}
+
/**
* Get a single post/comment content by author and permlink
*/
@@ -962,21 +988,43 @@ export async function getUserRelationshipList(
username: string,
type: 'blog' | 'ignore' | 'blacklist',
startFollowing: string = '',
- limit: number = 100
+ limit: number = 1000
): Promise {
try {
- // Use the traditional follow_api for getting full lists
- const result = await HiveClient.call('follow_api', 'get_following', [
- username,
- startFollowing,
- type,
- limit,
- ]);
+ const allUsernames: string[] = [];
+ let lastUsername = startFollowing;
+ const pageSize = Math.min(limit, 1000); // Hive API caps at 1000
+
+ // Paginate through all results
+ while (true) {
+ // Use bridge API instead of deprecated follow_api
+ const result = await HiveClient.call('bridge', 'get_following', {
+ account: username,
+ start: lastUsername,
+ type: type,
+ limit: pageSize,
+ });
+
+ if (!result || result.length === 0) break;
+
+ const usernames: string[] = result.map((item: any) => item.following);
+
+ // If we provided a startFollowing (or this is a paginated fetch),
+ // the first result is inclusive (skip it to avoid duplicates)
+ const newUsernames = lastUsername ? usernames.slice(1) : usernames;
+
+ if (newUsernames.length === 0) break;
+
+ allUsernames.push(...newUsernames);
+ lastUsername = usernames[usernames.length - 1];
+
+ // If we got fewer results than the page size, we've reached the end
+ if (result.length < pageSize) break;
+ }
- // Extract usernames from the result
- return result.map((item: any) => item.following);
+ return allUsernames;
} catch (error) {
- console.error('Error fetching user relationship list:', error);
+ console.error(`Error fetching user relationship list (${type}):`, error);
return [];
}
}
diff --git a/lib/hooks/useBlockchainWallet.ts b/lib/hooks/useBlockchainWallet.ts
index 13abb8d..1d7125b 100644
--- a/lib/hooks/useBlockchainWallet.ts
+++ b/lib/hooks/useBlockchainWallet.ts
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
+import { resilientFetch, fetchBalanceFromAPI, fetchRewardsFromAPI, type NormalizedBalance, type NormalizedRewards } from '../resilient-fetch';
import { getBlockchainAccountData, getBlockchainRewards } from '../hive-utils';
interface BalanceData {
@@ -55,6 +56,7 @@ export function useBlockchainWallet(username: string | null) {
const [rewardsData, setRewardsData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
+ const [source, setSource] = useState<'api' | 'rpc' | null>(null);
const fetchWalletData = async () => {
if (!username || username === "SPECTATOR") {
@@ -69,15 +71,28 @@ export function useBlockchainWallet(username: string | null) {
setError(null);
try {
- const [balanceResult, rewardsResult] = await Promise.all([
- getBlockchainAccountData(username),
- getBlockchainRewards(username),
- ]);
+ // Resilient balance fetch: API first, RPC fallback
+ const balanceResult = await resilientFetch(
+ async () => {
+ setSource('api');
+ return fetchBalanceFromAPI(username);
+ },
+ async () => {
+ setSource('rpc');
+ const rpcData = await getBlockchainAccountData(username);
+ return { account_name: username, ...rpcData };
+ },
+ 'wallet-balance'
+ );
- setBalanceData({
- account_name: username,
- ...balanceResult,
- });
+ // Resilient rewards fetch: API first, RPC fallback
+ const rewardsResult = await resilientFetch(
+ async () => fetchRewardsFromAPI(username) as Promise,
+ async () => getBlockchainRewards(username) as Promise,
+ 'wallet-rewards'
+ );
+
+ setBalanceData(balanceResult);
setRewardsData(rewardsResult);
} catch (err) {
console.error("Error fetching blockchain wallet data:", err);
@@ -102,6 +117,7 @@ export function useBlockchainWallet(username: string | null) {
rewardsData,
isLoading,
error,
+ source, // 'api' or 'rpc' — useful for debugging
refresh,
};
}
diff --git a/lib/hooks/useInView.ts b/lib/hooks/useInView.ts
index ab7acd5..8dd8679 100644
--- a/lib/hooks/useInView.ts
+++ b/lib/hooks/useInView.ts
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react';
-import { View } from 'react-native';
+import { View, AppState } from 'react-native';
interface UseInViewOptions {
threshold?: number;
@@ -17,7 +17,7 @@ export const useInView = (options: UseInViewOptions = {}) => {
// Continuously check visibility
const checkVisibility = () => {
- if (!ref) return;
+ if (!ref || AppState.currentState !== 'active') return;
ref.measure?.((x, y, width, height, pageX, pageY) => {
// Get screen dimensions
diff --git a/lib/hooks/useQueries.ts b/lib/hooks/useQueries.ts
index 1a4da6f..b063ec2 100644
--- a/lib/hooks/useQueries.ts
+++ b/lib/hooks/useQueries.ts
@@ -3,6 +3,7 @@ import {
getFeed,
getBalance,
getRewards } from '../api';
+import { useAuth } from '../auth-provider';
import { API_BASE_URL, LEADERBOARD_API_URL } from '../constants';
import { extractMediaFromBody } from '../utils';
import type { Post } from '../types';
@@ -78,10 +79,17 @@ async function fetchVideoFeed(): Promise {
}
export function useVideoFeed() {
+ const { mutedList } = useAuth();
+
return useQuery({
queryKey: VIDEO_FEED_QUERY_KEY,
queryFn: fetchVideoFeed,
staleTime: VIDEO_FEED_STALE_TIME,
+ select: (data) => {
+ if (!mutedList || mutedList.length === 0) return data;
+ const mutedSet = new Set(mutedList.map(u => u.toLowerCase()));
+ return data.filter(post => !mutedSet.has((post.author || '').toLowerCase()));
+ },
});
}
@@ -108,9 +116,9 @@ export async function warmUpVideoAssets(queryClient: QueryClient) {
if (!data || data.length === 0) return;
- // Prefetch thumbnails for the first 5 videos
+ // Prefetch thumbnails for the first 2 videos
const thumbnailUrls = data
- .slice(0, 5)
+ .slice(0, 2)
.map((v: VideoPost) => v.thumbnailUrl)
.filter(Boolean) as string[];
@@ -119,12 +127,54 @@ export async function warmUpVideoAssets(queryClient: QueryClient) {
});
// Prefetch avatar images
- const avatarUrls = [...new Set(data.slice(0, 5).map((v: VideoPost) => `https://images.hive.blog/u/${v.username}/avatar`))];
+ const avatarUrls = [...new Set(data.slice(0, 2).map((v: VideoPost) => `https://images.hive.blog/u/${v.username}/avatar`))];
avatarUrls.forEach((url: string) => {
Image.prefetch(url).catch(() => {});
});
}
+// ============================================================================
+// LOGIN-SCREEN PREFETCH — warm caches before user enters the app
+// ============================================================================
+
+/**
+ * Prefetch the main community feed (first page).
+ * Called on the login screen so the home tab loads instantly.
+ */
+export function prefetchCommunityFeed(queryClient: QueryClient) {
+ queryClient.prefetchQuery({
+ queryKey: ['feed', 1],
+ queryFn: () => getFeed(1, 10),
+ staleTime: 1000 * 60 * 2, // 2 minutes
+ });
+}
+
+/**
+ * Prefetch a user's profile after successful login.
+ */
+export function prefetchProfile(queryClient: QueryClient, username: string) {
+ queryClient.prefetchQuery({
+ queryKey: ['profile', username],
+ queryFn: async () => {
+ const response = await fetch(`${API_BASE_URL}/profile/${username}`);
+ const json = await response.json();
+ return json.success ? json.data : null;
+ },
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ });
+}
+
+/**
+ * Prefetch a user's balance data after successful login.
+ */
+export function prefetchBalance(queryClient: QueryClient, username: string) {
+ queryClient.prefetchQuery({
+ queryKey: ['balance', username],
+ queryFn: () => getBalance(username),
+ staleTime: 1000 * 60 * 2, // 2 minutes
+ });
+}
+
interface ProfileData {
name: string;
reputation: string;
diff --git a/lib/hooks/useSnaps.ts b/lib/hooks/useSnaps.ts
index eb4e489..f2430c1 100644
--- a/lib/hooks/useSnaps.ts
+++ b/lib/hooks/useSnaps.ts
@@ -1,12 +1,16 @@
import { useState, useEffect, useRef, useCallback } from 'react';
-import { getSnapsContainers, getContentReplies, ExtendedComment, SNAPS_CONTAINER_AUTHOR, COMMUNITY_TAG } from '../hive-utils';
+import { getSnapsContainers, getContentReplies, ExtendedComment, SNAPS_CONTAINER_AUTHOR, SNAPS_PAGE_MIN_SIZE, COMMUNITY_TAG, getDiscussions } from '../hive-utils';
+import { Discussion } from '@hiveio/dhive';
+import { FeedFilterType } from '../FeedFilterContext';
+import { useAuth } from '../auth-provider';
interface LastContainerInfo {
permlink: string;
date: string;
}
-export function useSnaps() {
+export function useSnaps(filter: FeedFilterType = 'Recent', username: string | null = null) {
+ const { mutedList } = useAuth();
const lastContainerRef = useRef(null);
const fetchedPermlinksRef = useRef>(new Set());
@@ -15,6 +19,14 @@ export function useSnaps() {
const [hasMore, setHasMore] = useState(true);
const [fetchTrigger, setFetchTrigger] = useState(0);
+ // Clear comments when filter changes
+ useEffect(() => {
+ setComments([]);
+ setHasMore(true);
+ lastContainerRef.current = null;
+ fetchedPermlinksRef.current = new Set();
+ }, [filter]);
+
// Filter comments by tag
function filterCommentsByTag(comments: ExtendedComment[], targetTag: string): ExtendedComment[] {
return comments.filter((commentItem) => {
@@ -33,59 +45,95 @@ export function useSnaps() {
// Fetch comments with progressive loading
async function getMoreSnaps(): Promise {
- const tag = COMMUNITY_TAG;
- const pageSize = 10;
- const allFilteredComments: ExtendedComment[] = [];
- let hasMoreData = true;
- let permlink = lastContainerRef.current?.permlink || '';
- let date = lastContainerRef.current?.date || new Date().toISOString();
- let iterationCount = 0;
- const maxIterations = 10;
- const allPermlinks = new Set(fetchedPermlinksRef.current);
-
- while (allFilteredComments.length < pageSize && hasMoreData && iterationCount < maxIterations) {
- iterationCount++;
-
- try {
- const result = await getSnapsContainers({
- lastPermlink: permlink,
- lastDate: date,
- });
-
- if (!result.length) {
- hasMoreData = false;
- break;
- }
-
- for (const resultItem of result) {
- if (allPermlinks.has(resultItem.permlink)) continue;
-
- const replies = await getContentReplies({
- author: SNAPS_CONTAINER_AUTHOR,
- permlink: resultItem.permlink,
+ // MOCKED: For now, we only show the Curated feed regardless of filter
+ const effectiveFilter = 'Curated';
+
+ if (effectiveFilter === 'Curated') {
+ const tag = COMMUNITY_TAG;
+ const pageSize = 10; // Target page size
+ const allFilteredComments: ExtendedComment[] = [];
+ let hasMoreData = true;
+ let permlink = lastContainerRef.current?.permlink || '';
+ let date = lastContainerRef.current?.date || new Date().toISOString();
+ let iterationCount = 0;
+ const maxIterations = 10; // Prevent infinite loops
+
+ const allPermlinks = new Set(fetchedPermlinksRef.current);
+
+ while (allFilteredComments.length < pageSize && hasMoreData && iterationCount < maxIterations) {
+ iterationCount++;
+
+ try {
+ const result = await getSnapsContainers({
+ lastPermlink: permlink,
+ lastDate: date,
});
-
- const filteredComments = filterCommentsByTag(replies, tag);
-
- allPermlinks.add(resultItem.permlink);
- filteredComments.forEach(c => allPermlinks.add(c.permlink));
- allFilteredComments.push(...filteredComments);
-
- permlink = resultItem.permlink;
- date = resultItem.created;
-
- if (allFilteredComments.length >= pageSize) break;
+
+ if (!result.length) {
+ hasMoreData = false;
+ break;
+ }
+
+ for (const resultItem of result) {
+ if (allPermlinks.has(resultItem.permlink)) continue;
+
+ const replies = await getContentReplies({
+ author: SNAPS_CONTAINER_AUTHOR,
+ permlink: resultItem.permlink,
+ });
+
+ const filteredComments = filterCommentsByTag(replies, tag);
+
+ allPermlinks.add(resultItem.permlink);
+ filteredComments.forEach(c => allPermlinks.add(c.permlink));
+ allFilteredComments.push(...filteredComments);
+ permlink = resultItem.permlink;
+ date = resultItem.created;
+
+ if (allFilteredComments.length >= pageSize) break;
+ }
+ } catch (error) {
+ console.error('Error fetching snaps:', error);
+ hasMoreData = false;
}
- } catch (error) {
- console.error('Error fetching snaps:', error);
- hasMoreData = false;
}
- }
+
+ fetchedPermlinksRef.current = allPermlinks;
+ lastContainerRef.current = { permlink, date };
+ allFilteredComments.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
+ return allFilteredComments;
+ } else {
+ // Use getDiscussions for other filters
+ let type: 'created' | 'trending' | 'hot' | 'feed' = 'created';
+ let tag = COMMUNITY_TAG;
+
+ if (filter === 'Trending') type = 'trending';
+ if (filter === 'Following') {
+ if (!username || username === 'SPECTATOR') return [];
+ type = 'feed';
+ tag = username;
+ }
- fetchedPermlinksRef.current = allPermlinks;
- lastContainerRef.current = { permlink, date };
- allFilteredComments.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
- return allFilteredComments;
+ const lastPost = comments.length > 0 ? comments[comments.length - 1] : null;
+
+ const results = await getDiscussions(type, {
+ tag,
+ limit: 10,
+ start_author: lastPost?.author,
+ start_permlink: lastPost?.permlink
+ });
+
+ // Filter out muted users and duplicates
+ const mutedSet = new Set(mutedList.map(u => u.toLowerCase()));
+ const filteredResults = results.filter(r =>
+ !mutedSet.has(r.author.toLowerCase()) &&
+ !fetchedPermlinksRef.current.has(r.permlink)
+ );
+
+ filteredResults.forEach(r => fetchedPermlinksRef.current.add(r.permlink));
+
+ return filteredResults as unknown as ExtendedComment[];
+ }
}
// Single effect for all fetching
diff --git a/lib/hooks/useUserComments.ts b/lib/hooks/useUserComments.ts
index 133bc44..9663b8f 100644
--- a/lib/hooks/useUserComments.ts
+++ b/lib/hooks/useUserComments.ts
@@ -6,10 +6,11 @@ interface LastPostInfo {
permlink: string;
}
-export function useUserComments(username: string | null) {
+export function useUserComments(username: string | null, mutedList: string[] = []) {
const lastPostRef = useRef(null);
const fetchedPermlinksRef = useRef>(new Set());
const prevUsernameRef = useRef(null);
+ const prevMutedListRef = useRef(mutedList);
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
@@ -123,9 +124,18 @@ export function useUserComments(username: string | null) {
setPosts((prevPosts) => {
const existingPermlinks = new Set(prevPosts.map((p) => p.permlink));
- const uniquePosts = newPosts.filter((p: any) => !existingPermlinks.has(p.permlink));
+ const mutedLower = mutedList.map(m => m.toLowerCase());
+ const uniquePosts = newPosts.filter((p: any) =>
+ !existingPermlinks.has(p.permlink) &&
+ !mutedLower.includes(p.author.toLowerCase())
+ );
+
+ if (uniquePosts.length === 0 && newPosts.length > 0) {
+ // All new posts were filtered out by mutes, try to get more
+ setFetchTrigger(t => t + 1);
+ }
- if (uniquePosts.length === 0) {
+ if (uniquePosts.length === 0 && newPosts.length === 0) {
setHasMore(false);
}
@@ -142,7 +152,20 @@ export function useUserComments(username: string | null) {
fetchPosts();
return () => { cancelled = true; };
- }, [fetchTrigger, username]);
+ }, [fetchTrigger, username, mutedList]);
+
+ // Reset when mutedList changes significantly (e.g. login/logout or new mute)
+ useEffect(() => {
+ if (JSON.stringify(prevMutedListRef.current) !== JSON.stringify(mutedList)) {
+ prevMutedListRef.current = mutedList;
+ // Re-fetch and clear current posts
+ lastPostRef.current = null;
+ fetchedPermlinksRef.current = new Set();
+ setPosts([]);
+ setHasMore(true);
+ setFetchTrigger((t) => t + 1);
+ }
+ }, [mutedList]);
// Load next page — just bump the trigger
const loadNextPage = useCallback(() => {
diff --git a/lib/markdown/MarkdownProcessor.ts b/lib/markdown/MarkdownProcessor.ts
new file mode 100644
index 0000000..8a3757c
--- /dev/null
+++ b/lib/markdown/MarkdownProcessor.ts
@@ -0,0 +1,72 @@
+export interface ProcessedMarkdown {
+ originalContent: string;
+ contentWithPlaceholders: string;
+}
+
+import { Registry } from './providers';
+
+export class MarkdownProcessor {
+ /**
+ * Processes markdown content to replace specific media links with placeholders
+ * for the mobile app to render natively.
+ */
+ static process(content: string): ProcessedMarkdown {
+ if (!content) return { originalContent: '', contentWithPlaceholders: '' };
+
+ let processedContent = content;
+
+ // A. Use Modular Providers (Registry)
+ // We iterate through all registered providers and apply their patterns
+ Registry.getAllProviders().forEach(provider => {
+ provider.patterns.forEach(pattern => {
+ processedContent = processedContent.replace(pattern, (match) => {
+ const id = provider.resolve(match);
+ return `[[${provider.name}:${id}]]`;
+ });
+ });
+ });
+
+ // B. Generic Media Cleanup (for tags that clutter the rendering)
+
+
+ // 5. Instagram links
+ processedContent = processedContent.replace(
+ /(?:^|\s)https?:\/\/(www\.)?instagram\.com\/p\/([\w-]+)\/?[^\s]*(?=\s|$)/gim,
+ '[[INSTAGRAM:$2]]'
+ );
+
+ // 6. Zora Coin/NFT links
+ processedContent = processedContent.replace(
+ /(?:^|\s)https?:\/\/(?:www\.)?(?:zora\.co|skatehive\.app)\/coin\/(0x[a-fA-F0-9]{40}(?::\d+)?).*?(?=\s|$)/gim,
+ '[[ZORACOIN:$1]]'
+ );
+
+ // 7. Snapshot Proposals
+ processedContent = processedContent.replace(
+ /(?:^|\s)https?:\/\/(?:www\.)?(?:snapshot\.(?:org|box)|demo\.snapshot\.org)\/.*\/proposal\/(0x[a-fA-F0-9]{64})(?=\s|$)/gim,
+ '[[SNAPSHOT:$1]]'
+ );
+
+ // 10. Deep Clean HTML tags that clutter or break rendering
+ // Strip specific Hive/PeakD schema wrappers
+ processedContent = processedContent.replace(//gi, '');
+
+ processedContent = processedContent.replace(/
/gi, '\n\n');
+ processedContent = processedContent.replace(/<\/center>/gi, '\n\n');
+ processedContent = processedContent.replace(/]*>/gi, '\n');
+ processedContent = processedContent.replace(/<\/div>/gi, '\n');
+ processedContent = processedContent.replace(/ ]*>/gi, '');
+ processedContent = processedContent.replace(/ ]*>/gi, '');
+ processedContent = processedContent.replace(/