diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9f89cf7..f3114da 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -98,20 +98,20 @@ function FeedHeaderTitle() { const { filter, setFilter } = useFeedFilter(); const [showDropdown, setShowDropdown] = useState(false); - const filters: - ('Skatehive' | 'Recent' | 'Following' | 'Curated' | 'Trending')[] = - ['Skatehive', 'Recent', 'Following', 'Curated', 'Trending']; + const filters: ('Recent' | 'Following' | 'Trending')[] = ['Recent', 'Following', 'Trending']; + + const isSkatehiveOrRecent = filter === 'Skatehive' || filter === 'Recent'; return ( - {/* TODO: Add filter dropdown back in */} - {/* setShowDropdown(true)} style={{ flexDirection: 'row', alignItems: 'center' }}> */} - - {/* {filter} */} - Skatehive - - {/* */} - {/* */} + setShowDropdown(true)} style={{ flexDirection: 'row', alignItems: 'center' }}> + + {isSkatehiveOrRecent ? 'Skatehive' : filter} + + {!isSkatehiveOrRecent && ( + + )} + 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} - - - ))} + {filters.map((f) => { + const isActive = filter === f || (f === 'Recent' && filter === 'Skatehive'); + return ( + { + setFilter(f); + setShowDropdown(false); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: isActive ? 'rgba(50, 205, 50, 0.1)' : 'transparent', + borderRadius: 8, + marginBottom: 4, + }} + > + + {f} + + + ); + })} - + ); } diff --git a/app/(tabs)/create.tsx b/app/(tabs)/create.tsx index 65e3893..799e264 100644 --- a/app/(tabs)/create.tsx +++ b/app/(tabs)/create.tsx @@ -8,7 +8,6 @@ import { TouchableWithoutFeedback, View, ScrollView, - ActivityIndicator, Alert, StyleSheet, } from "react-native"; @@ -37,6 +36,7 @@ import { getLastSnapsContainer, } from "~/lib/hive-utils"; import { theme } from "~/lib/theme"; +import { ThemedLoading } from "~/components/ui/ThemedLoading"; export default function CreatePost() { const { username, session } = useAuth(); @@ -363,10 +363,7 @@ export default function CreatePost() { {isSelectingMedia ? ( <> - + Selecting... @@ -388,9 +385,12 @@ export default function CreatePost() { onPress={handlePost} disabled={(!content.trim() && !media) || isUploading} > - - {isUploading ? "Publishing..." : "Share"} - + + {isUploading && } + + {isUploading ? "Publishing..." : "Share"} + + diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 7501ea1..ef54cee 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -3,7 +3,6 @@ import { View, ScrollView, Image, - ActivityIndicator, Pressable, RefreshControl, StyleSheet, @@ -37,46 +36,13 @@ import type { Discussion } from '@hiveio/dhive'; import { extractMediaFromBody } from '~/lib/utils'; import { GridVideoTile } from "~/components/Profile/GridVideoTile"; import { VideoPlayer } from '~/components/Feed/VideoPlayer'; +import { ThemedLoading } from "~/components/ui/ThemedLoading"; +import { GridSkeleton } from "~/components/ui/Skeletons"; const GRID_COLS = 3; const GRID_GAP = 2; const SCREEN_WIDTH = Dimensions.get('window').width; -// Skeleton grid shown while posts load -const SkeletonTile = React.memo(({ size, delay }: { size: number; delay: number }) => { - const opacity = useRef(new Animated.Value(0.3)).current; - - useEffect(() => { - const pulse = Animated.loop( - Animated.sequence([ - Animated.timing(opacity, { toValue: 0.6, duration: 800, delay, useNativeDriver: true }), - Animated.timing(opacity, { toValue: 0.3, duration: 800, useNativeDriver: true }), - ]) - ); - pulse.start(); - return () => pulse.stop(); - }, []); - - return ; -}); - -const GridSkeleton = ({ tileSize }: { tileSize: number }) => ( - - {Array.from({ length: 12 }).map((_, i) => ( - - ))} - -); - -const skeletonStyles = StyleSheet.create({ - container: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: GRID_GAP, - justifyContent: 'flex-start', - }, -}); - // Map common country names/codes to flag emojis function countryToFlag(location: string): string { const loc = location.trim().toUpperCase(); @@ -237,15 +203,15 @@ export default function ProfileScreen() { const { hiveAccount, isLoading: isLoadingProfile, error } = useHiveAccount(profileUsername); // --- Fetching Logic (API vs RPC) --- - + // 1. RPC Fallback (original hook) // We only pass the username if the API is disabled to save resources - const { - posts: rpcPosts, - isLoading: isRpcLoading, - loadNextPage: loadNextPageRpc, - hasMore: rpcHasMore, - refresh: refreshRpc + const { + posts: rpcPosts, + isLoading: isRpcLoading, + loadNextPage: loadNextPageRpc, + hasMore: rpcHasMore, + refresh: refreshRpc } = useUserComments(SnapConfig.useApi ? null : profileUsername, blockedList); // 2. API Logic (New migration) @@ -273,14 +239,14 @@ export default function ProfileScreen() { setIsApiLoading(true); setApiError(null); console.log(`[Profile Snaps] Fetching page ${page} for @${profileUsername} (refresh: ${refresh})`); - + const url = `${API_BASE_URL}/feed?author=${profileUsername}&page=${page}&limit=${API_LIMIT}`; const response = await fetch(url); - + if (!response.ok) { throw new Error(`API error: ${response.status}`); } - + const result = await response.json(); if (result.success) { @@ -332,7 +298,7 @@ export default function ProfileScreen() { // Initial load or username change (for API only) useEffect(() => { if (!SnapConfig.useApi) return; - + console.log(`[Profile Snaps] Resetting and initial load for @${profileUsername}`); setApiPosts([]); setApiPage(1); @@ -539,7 +505,11 @@ export default function ProfileScreen() { }; if (isLoadingProfile) { - return ; + return ( + + + + ); } // Only show error for non-SPECTATOR users when there's an actual error or missing account @@ -615,7 +585,7 @@ export default function ProfileScreen() { disabled={isBlockLoading} > {isBlockLoading ? ( - + ) : ( Blocked @@ -632,7 +602,7 @@ export default function ProfileScreen() { disabled={isFollowLoading} > {isFollowLoading ? ( - + ) : ( {apiError} - fetchUserSnaps(apiPage + 1)} > @@ -749,10 +719,10 @@ export default function ProfileScreen() { ); } - if (!isLoadingPosts) return null; + if (!isLoadingPosts || userPosts.length === 0) return null; return ( - + ); }; diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index 136588b..425923f 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -5,7 +5,6 @@ import { TextInput, FlatList, Pressable, - ActivityIndicator, Dimensions, ScrollView, Keyboard @@ -13,6 +12,7 @@ import { import { Ionicons } from "@expo/vector-icons"; import { Text } from "~/components/ui/text"; import { theme } from "~/lib/theme"; +import { ThemedLoading } from "~/components/ui/ThemedLoading"; import { SafeAreaView } from "react-native-safe-area-context"; import { useSearch, SearchType, TimeFilter } from "~/lib/hooks/useSearch"; import { PostCard } from "~/components/Feed/PostCard"; @@ -231,7 +231,7 @@ export default function SearchScreen() { if (isSnapsFetchingNextPage) { return ( - + ); } @@ -291,7 +291,7 @@ export default function SearchScreen() { {isLoading && snaps.length === 0 && users.length === 0 ? ( - + ) : ( searchType === 'users' ? ( diff --git a/app/(tabs)/videos.tsx b/app/(tabs)/videos.tsx index c62dac4..9da0b86 100644 --- a/app/(tabs)/videos.tsx +++ b/app/(tabs)/videos.tsx @@ -10,6 +10,7 @@ import { Pressable, Share, Animated, + Platform, } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import AsyncStorage from "@react-native-async-storage/async-storage"; @@ -27,7 +28,7 @@ import { useScrollDirection } from "~/lib/ScrollDirectionContext"; import { theme } from "~/lib/theme"; import { useAppSettings } from "~/lib/AppSettingsContext"; import { LoadingScreen } from "~/components/ui/LoadingScreen"; -import { MatrixRain } from "~/components/ui/loading-effects/MatrixRain"; +import { ThemedLoading } from "~/components/ui/ThemedLoading"; const { height: WINDOW_HEIGHT, width: SCREEN_WIDTH } = Dimensions.get("window"); @@ -67,6 +68,16 @@ export default function VideosScreen() { const uiOpacity = useRef(new Animated.Value(1)).current; const uiFadeTimeout = useRef(null); + // Listen for logout to gracefully release VideoPlayer before unmounting the screen + const [isLoggingOut, setIsLoggingOut] = useState(false); + useEffect(() => { + if (!session && username !== 'SPECTATOR') { + setIsLoggingOut(true); + } else { + setIsLoggingOut(false); + } + }, [session, username]); + const resetUiFade = useCallback(() => { // Cancel existing timeout if (uiFadeTimeout.current) { @@ -132,6 +143,7 @@ export default function VideosScreen() { useNativeDriver: true, }).start(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + setScrollDirection('up'); // Ensure bars are visible when navigating back to feed router.push('/(tabs)/feed'); // Reset after a short delay to ensure navigation transition finishes setTimeout(() => { @@ -354,8 +366,8 @@ export default function VideosScreen() { style={StyleSheet.absoluteFill} onPress={handleTap} > - {/* Only mount VideoPlayer for current and adjacent items */} - {isNearby ? ( + {/* Only mount VideoPlayer for current and adjacent items on Android, but keep iOS mounted for stable native playback */} + {!isLoggingOut && (Platform.OS === 'ios' || isNearby) ? ( - + )} @@ -430,7 +442,7 @@ export default function VideosScreen() { disabled={isVoting} > {isVoting ? ( - + ) : ( - - - Loading more bangers... + ) : null } diff --git a/app/index.tsx b/app/index.tsx index 1eaf454..b329071 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -110,7 +110,7 @@ const BackgroundVideo = () => { const LoginBackground = () => { const { settings } = useAppSettings(); - const isMatrix = settings.loginBackground === "matrix"; + const isMatrix = settings.theme === "matrix"; const glitchX = React.useRef(new Animated.Value(0)).current; const revealAnim = React.useRef(new Animated.Value(0)).current; diff --git a/components/Feed/Conversation.tsx b/components/Feed/Conversation.tsx index ab2846b..567341d 100644 --- a/components/Feed/Conversation.tsx +++ b/components/Feed/Conversation.tsx @@ -4,10 +4,10 @@ import { ScrollView, Pressable, StyleSheet, - ActivityIndicator, } from 'react-native'; import { FontAwesome } from '@expo/vector-icons'; import { Text } from '../ui/text'; +import { ThemedLoading } from '../ui/ThemedLoading'; import { PostCard } from '../Feed/PostCard'; import { useReplies } from '~/lib/hooks/useReplies'; import { useAuth } from '~/lib/auth-provider'; @@ -59,7 +59,7 @@ export function Conversation({ discussion, onClose }: ConversationProps) { {isLoading ? ( - + Loading comments... ) : error ? ( diff --git a/components/Feed/ConversationDrawer.tsx b/components/Feed/ConversationDrawer.tsx index fc5947e..6168518 100644 --- a/components/Feed/ConversationDrawer.tsx +++ b/components/Feed/ConversationDrawer.tsx @@ -7,7 +7,6 @@ import { PanResponder, Dimensions, Pressable, - ActivityIndicator, ScrollView, KeyboardAvoidingView, Platform, @@ -15,6 +14,7 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '../ui/text'; +import { ThemedLoading } from '../ui/ThemedLoading'; import { PostCard } from './PostCard'; import { ConversationReply } from './ConversationReply'; import { ReplyComposer } from '../ui/ReplyComposer'; @@ -183,7 +183,7 @@ export function ConversationDrawer({ {isPostLoading ? ( - + ) : post ? ( {(isCommentsLoading || isPostLoading) && ( - + )} diff --git a/components/Feed/ConversationReply.tsx b/components/Feed/ConversationReply.tsx index 898907f..552abea 100644 --- a/components/Feed/ConversationReply.tsx +++ b/components/Feed/ConversationReply.tsx @@ -4,12 +4,12 @@ import { Pressable, Image, StyleSheet, - ActivityIndicator, } from 'react-native'; import { FontAwesome } from '@expo/vector-icons'; import { router } from 'expo-router'; import * as Haptics from 'expo-haptics'; import { Text } from '../ui/text'; +import { ThemedLoading } from '../ui/ThemedLoading'; import { EnhancedMarkdownRenderer } from '../markdown/EnhancedMarkdownRenderer'; import { MediaPreview } from './MediaPreview'; import { ReplyComposer } from '../ui/ReplyComposer'; @@ -226,8 +226,8 @@ export function ConversationReply({ disabled={isVoting} > {isVoting ? ( - - ) : ( + + ) : ( <> {voteCount} diff --git a/components/Feed/EmbedPlayer.tsx b/components/Feed/EmbedPlayer.tsx index 9f70b2b..e34a179 100644 --- a/components/Feed/EmbedPlayer.tsx +++ b/components/Feed/EmbedPlayer.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; -import { View, StyleSheet, Text, ActivityIndicator } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { WebView } from 'react-native-webview'; import { theme } from '../../lib/theme'; +import { ThemedLoading } from '../ui/ThemedLoading'; interface EmbedPlayerProps { url: string; @@ -45,7 +46,7 @@ export const EmbedPlayer = ({ url }: EmbedPlayerProps) => { /> {isLoading && ( - + )} @@ -120,7 +121,7 @@ export const EmbedPlayer = ({ url }: EmbedPlayerProps) => { /> {isLoading && ( - + )} diff --git a/components/Feed/Feed.tsx b/components/Feed/Feed.tsx index b002e76..c7d7717 100644 --- a/components/Feed/Feed.tsx +++ b/components/Feed/Feed.tsx @@ -15,7 +15,8 @@ import { Ionicons } from "@expo/vector-icons"; import * as Haptics from "expo-haptics"; import { Text } from "../ui/text"; import { PostCard } from "./PostCard"; -import { ActivityIndicator } from "react-native"; +import { useAppSettings } from "~/lib/AppSettingsContext"; +import { ThemedLoading } from "../ui/ThemedLoading"; import { useAuth } from "~/lib/auth-provider"; import { Post } from '~/lib/types'; import { useFeedFilter } from '~/lib/FeedFilterContext'; @@ -238,7 +239,7 @@ function FeedContent({ refreshTrigger, onRefresh }: FeedProps) { const ListFooterComponent = isLoading ? ( - + ) : null; diff --git a/components/Feed/MediaPreview.tsx b/components/Feed/MediaPreview.tsx index fb938c4..a058c87 100644 --- a/components/Feed/MediaPreview.tsx +++ b/components/Feed/MediaPreview.tsx @@ -15,6 +15,7 @@ interface MediaPreviewProps { isModalVisible: boolean; onCloseModal: () => void; isVisible?: boolean; // For autoplay control + thumbnailUrl?: string | null; } // For calculating image dimensions @@ -28,6 +29,7 @@ export function MediaPreview({ isModalVisible, onCloseModal, isVisible = true, + thumbnailUrl, }: MediaPreviewProps) { // Track dimensions for each image to maintain proper aspect ratio const [imageDimensions, setImageDimensions] = useState>({}); @@ -91,6 +93,7 @@ export function MediaPreview({ ) : item.type === 'embed' ? ( diff --git a/components/Feed/PostCard.tsx b/components/Feed/PostCard.tsx index daecd9b..7d338d4 100644 --- a/components/Feed/PostCard.tsx +++ b/components/Feed/PostCard.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { FontAwesome, Ionicons } from '@expo/vector-icons'; // import * as SecureStore from 'expo-secure-store'; import * as Haptics from 'expo-haptics'; -import { Pressable, View, Linking, ActivityIndicator, StyleSheet, Modal, TextInput, ScrollView } from 'react-native'; +import { Pressable, View, Linking, StyleSheet, Modal, TextInput, ScrollView } from 'react-native'; import { Image } from 'expo-image'; import { router } from 'expo-router'; // import { API_BASE_URL } from '~/lib/constants'; @@ -24,6 +24,7 @@ import { useAppSettings } from '~/lib/AppSettingsContext'; import { MediaPreview } from './MediaPreview'; import { CommentBottomSheet } from '../ui/CommentBottomSheet'; import { EnhancedMarkdownRenderer } from '../markdown/EnhancedMarkdownRenderer'; +import { ThemedLoading } from '../ui/ThemedLoading'; const ConversationDrawer = React.lazy(() => import('./ConversationDrawer').then(m => ({ default: m.ConversationDrawer })) ); @@ -525,9 +526,11 @@ export const PostCard = React.memo(({ post, currentUsername, isStatic, isMinimiz disabled={isFollowLoading} > {isFollowLoading ? ( - + ) : ( - Follow + + {isFollowing ? 'Following' : 'Follow'} + )} )} @@ -597,7 +600,9 @@ export const PostCard = React.memo(({ post, currentUsername, isStatic, isMinimiz disabled={isVoting} > {isVoting ? ( - + + + ) : ( )} @@ -630,7 +635,7 @@ export const PostCard = React.memo(({ post, currentUsername, isStatic, isMinimiz hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} > {isVoting ? ( - @@ -716,7 +721,7 @@ export const PostCard = React.memo(({ post, currentUsername, isStatic, isMinimiz disabled={isDeleting} > {isDeleting ? ( - + ) : ( Delete Snap )} @@ -791,7 +796,7 @@ export const PostCard = React.memo(({ post, currentUsername, isStatic, isMinimiz disabled={!selectedReportReason || isSubmittingReport} > {isSubmittingReport ? ( - + ) : ( Submit Report )} diff --git a/components/Feed/VideoWithAutoplay.tsx b/components/Feed/VideoWithAutoplay.tsx index 412ed37..8b423b7 100644 --- a/components/Feed/VideoWithAutoplay.tsx +++ b/components/Feed/VideoWithAutoplay.tsx @@ -1,25 +1,30 @@ import React, { useState } from 'react'; -import { View, Pressable, StyleSheet, ViewStyle } from 'react-native'; +import { View, Pressable, StyleSheet, ViewStyle, Platform } from 'react-native'; +import { Image } from 'expo-image'; import { FontAwesome } from '@expo/vector-icons'; import { useIsFocused } from '@react-navigation/native'; import { VideoPlayer } from './VideoPlayer'; import { useInView } from '../../lib/hooks/useInView'; import { theme } from '../../lib/theme'; +import { ThemedLoading } from '../ui/ThemedLoading'; interface VideoWithAutoplayProps { url: string; isVisible?: boolean; + thumbnailUrl?: string | null; style?: ViewStyle; requireInteraction?: boolean; // New prop to control autoplay behavior } export function VideoWithAutoplay({ url, + thumbnailUrl, isVisible = true, style, requireInteraction = false }: VideoWithAutoplayProps) { const [hasInteracted, setHasInteracted] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); const isFocused = useIsFocused(); const { ref, isInView } = useInView({ threshold: 0.5 }); @@ -39,10 +44,11 @@ export function VideoWithAutoplay({ return ( - {isInView && ( + {(Platform.OS === 'ios' || isInView) && ( setIsPlaying(true)} /> )} @@ -52,6 +58,35 @@ export function VideoWithAutoplay({ )} + + {/* Thumbnail overlay until video plays */} + {!isPlaying && ( + <> + {thumbnailUrl ? ( + + + {(!requireInteraction || hasInteracted) && ( + + + + )} + + ) : ( + + {(!requireInteraction || hasInteracted) ? ( + + ) : ( + + )} + + )} + + )} ); @@ -68,6 +103,26 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, + posterOverlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 2, + }, + posterImage: { + width: '100%', + height: '100%', + }, + playIconOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.15)', + }, + spinnerOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + zIndex: 2, + }, playOverlay: { position: 'absolute', top: 0, diff --git a/components/Leaderboard/leaderboard.tsx b/components/Leaderboard/leaderboard.tsx index e218901..c8ea275 100644 --- a/components/Leaderboard/leaderboard.tsx +++ b/components/Leaderboard/leaderboard.tsx @@ -112,7 +112,7 @@ export function Leaderboard({ currentUsername }: LeaderboardProps) { return ( - + {/* Header Removed for more space */} @@ -220,7 +220,7 @@ const styles = StyleSheet.create({ scrollContainer: { width: '100%', paddingTop: 100, // Space for absolute header - paddingBottom: 100, // Space for absolute tab bar + paddingBottom: 130, // Space for absolute tab bar + extra space for last item }, loadingContainer: { flex: 1, diff --git a/components/Profile/EditProfileModal.tsx b/components/Profile/EditProfileModal.tsx index 1d7abb8..01a81a6 100644 --- a/components/Profile/EditProfileModal.tsx +++ b/components/Profile/EditProfileModal.tsx @@ -6,7 +6,6 @@ import { FlatList, Pressable, Image, - ActivityIndicator, StyleSheet, KeyboardAvoidingView, Platform, @@ -17,6 +16,7 @@ import * as Haptics from 'expo-haptics'; import { Text } from '~/components/ui/text'; import { Input } from '~/components/ui/input'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '~/components/ui/ThemedLoading'; import { useAuth } from '~/lib/auth-provider'; import { useToast } from '~/lib/toast-provider'; import { HiveClient, updateProfile } from '~/lib/hive-utils'; @@ -242,7 +242,7 @@ export function EditProfileModal({ visible, onClose, currentProfile, onSaved }: style={[styles.saveButton, (!hasChanges || saving) && styles.saveButtonDisabled]} > {saving ? ( - + ) : ( Save )} @@ -262,7 +262,7 @@ export function EditProfileModal({ visible, onClose, currentProfile, onSaved }: )} {uploadingAvatar ? ( - + ) : ( )} diff --git a/components/Profile/FollowersModal.tsx b/components/Profile/FollowersModal.tsx index 9584740..3bd7b5d 100644 --- a/components/Profile/FollowersModal.tsx +++ b/components/Profile/FollowersModal.tsx @@ -5,7 +5,6 @@ import { FlatList, Pressable, Image, - ActivityIndicator, StyleSheet, Animated, PanResponder, @@ -16,6 +15,7 @@ import { FontAwesome, Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '../ui/text'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '../ui/ThemedLoading'; import { getFollowing, getFollowers, setUserRelationship } from '~/lib/hive-utils'; import { useAuth } from '~/lib/auth-provider'; @@ -242,7 +242,7 @@ export const FollowersModal: React.FC = ({ if (!loadingMore) return null; return ( - + ); }; @@ -295,7 +295,7 @@ export const FollowersModal: React.FC = ({ {/* User List */} {loading ? ( - + Loading {type === 'muted' || type === 'blocked' ? 'blocked users' : type}... ) : ( diff --git a/components/markdown/embeds/BaseVideoEmbed.tsx b/components/markdown/embeds/BaseVideoEmbed.tsx index 86309f9..53d5288 100644 --- a/components/markdown/embeds/BaseVideoEmbed.tsx +++ b/components/markdown/embeds/BaseVideoEmbed.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { View, StyleSheet, ActivityIndicator, Pressable, useWindowDimensions } from 'react-native'; +import { View, StyleSheet, Pressable, useWindowDimensions } from 'react-native'; import { WebView } from 'react-native-webview'; import { useIsFocused } from '@react-navigation/native'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '~/components/ui/ThemedLoading'; import { VideoConfig } from '~/lib/config/VideoConfig'; import { useAppSettings } from '~/lib/AppSettingsContext'; @@ -180,7 +181,7 @@ export const BaseVideoEmbed = ({ url, isVisible, isPrefetch, author, provider = /> {loading && ( - + )} diff --git a/components/markdown/embeds/InstagramEmbed.tsx b/components/markdown/embeds/InstagramEmbed.tsx index dd522b2..597e0d1 100644 --- a/components/markdown/embeds/InstagramEmbed.tsx +++ b/components/markdown/embeds/InstagramEmbed.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { WebView } from 'react-native-webview'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '~/components/ui/ThemedLoading'; interface InstagramEmbedProps { url: string; @@ -23,7 +24,7 @@ export const InstagramEmbed = ({ url }: InstagramEmbedProps) => { /> {loading && ( - + )} diff --git a/components/markdown/embeds/SnapshotEmbed.tsx b/components/markdown/embeds/SnapshotEmbed.tsx index 515363e..06ea118 100644 --- a/components/markdown/embeds/SnapshotEmbed.tsx +++ b/components/markdown/embeds/SnapshotEmbed.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { WebView } from 'react-native-webview'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '~/components/ui/ThemedLoading'; interface SnapshotEmbedProps { url: string; @@ -19,7 +20,7 @@ export const SnapshotEmbed = ({ url }: SnapshotEmbedProps) => { /> {loading && ( - + )} diff --git a/components/markdown/embeds/ZoraEmbed.tsx b/components/markdown/embeds/ZoraEmbed.tsx index 3c4020b..c79e300 100644 --- a/components/markdown/embeds/ZoraEmbed.tsx +++ b/components/markdown/embeds/ZoraEmbed.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { WebView } from 'react-native-webview'; import { theme } from '~/lib/theme'; +import { ThemedLoading } from '~/components/ui/ThemedLoading'; interface ZoraEmbedProps { address: string; @@ -22,7 +23,7 @@ export const ZoraEmbed = ({ address }: ZoraEmbedProps) => { /> {loading && ( - + )} diff --git a/components/notifications/NotificationsScreen.tsx b/components/notifications/NotificationsScreen.tsx index 6a57b3e..25dea36 100644 --- a/components/notifications/NotificationsScreen.tsx +++ b/components/notifications/NotificationsScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, FlatList, StyleSheet, RefreshControl, ActivityIndicator } from 'react-native'; +import { View, FlatList, StyleSheet, RefreshControl } from 'react-native'; import { useNotifications } from '~/lib/hooks/useNotifications'; import { useNotificationContext } from '~/lib/notifications-context'; import { NotificationItem } from './NotificationItem'; @@ -8,7 +8,8 @@ import { Button } from '../ui/button'; import { theme } from '~/lib/theme'; import { useAuth } from '~/lib/auth-provider'; import { useToast } from '~/lib/toast-provider'; -import { MatrixRain } from '~/components/ui/loading-effects/MatrixRain'; +import { ThemedLoading } from '../ui/ThemedLoading'; +import { LoadingScreen } from '../ui/LoadingScreen'; import type { HiveNotification } from '~/lib/types'; export const NotificationsScreen = React.memo(() => { @@ -41,18 +42,17 @@ export const NotificationsScreen = React.memo(() => { }; const handleLoadMore = () => { - if (hasMore && !isLoadingMore) { + if (hasMore && !isLoadingMore && notifications.length > 0) { loadMore(); } }; const renderFooter = () => { - if (!isLoadingMore) return null; + if (!isLoadingMore || notifications.length === 0) return null; return ( - - Loading more... + ); }; @@ -60,10 +60,7 @@ export const NotificationsScreen = React.memo(() => { const renderEmptyState = () => { if (isLoading) { return ( - - - Loading notifications... - + ); } @@ -81,7 +78,7 @@ export const NotificationsScreen = React.memo(() => { if (username === 'SPECTATOR') { return ( - + Please log in to view notifications @@ -207,15 +204,7 @@ const styles = StyleSheet.create({ fontFamily: theme.fonts.regular, }, footerLoader: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', + width: '100%', padding: theme.spacing.md, - gap: theme.spacing.xs, - }, - loadingText: { - fontSize: theme.fontSizes.sm, - color: theme.colors.muted, - fontFamily: theme.fonts.regular, }, }); diff --git a/components/ui/LoadingScreen.tsx b/components/ui/LoadingScreen.tsx index 8030438..566a6ad 100644 --- a/components/ui/LoadingScreen.tsx +++ b/components/ui/LoadingScreen.tsx @@ -2,16 +2,30 @@ import React from "react"; import { View, ActivityIndicator, StyleSheet } from "react-native"; import { getLoadingEffect } from "./loading-effects"; import { theme } from "~/lib/theme"; +import { useAppSettings } from "~/lib/AppSettingsContext"; +import { SkeletonBackground } from "./loading-effects/SkeletonBackground"; export function LoadingScreen() { - const BackgroundEffect = getLoadingEffect("matrix").component; + const { settings } = useAppSettings(); + + const renderBackground = () => { + switch (settings.theme) { + case 'matrix': + const MatrixRainComp = getLoadingEffect("matrix").component; + return ; + case 'skatehive': + default: + return ( + + + + ); + } + }; return ( - - {/* - - */} + {renderBackground()} ); } diff --git a/components/ui/RecentMediaGallery.tsx b/components/ui/RecentMediaGallery.tsx index 0083d24..34f6f2f 100644 --- a/components/ui/RecentMediaGallery.tsx +++ b/components/ui/RecentMediaGallery.tsx @@ -5,11 +5,11 @@ import { Pressable, StyleSheet, Alert, - ActivityIndicator, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import * as MediaLibrary from "expo-media-library"; import { Text } from "./text"; +import { ThemedLoading } from "./ThemedLoading"; import { theme } from "~/lib/theme"; interface MediaAsset { @@ -101,7 +101,7 @@ export function RecentMediaGallery({ return ( - + Requesting permissions... @@ -132,7 +132,7 @@ export function RecentMediaGallery({ return ( - + Loading recent media... diff --git a/components/ui/ReplyComposer.tsx b/components/ui/ReplyComposer.tsx index 5507553..d767f13 100644 --- a/components/ui/ReplyComposer.tsx +++ b/components/ui/ReplyComposer.tsx @@ -5,7 +5,6 @@ import { Pressable, Image, StyleSheet, - ActivityIndicator, Alert, Keyboard, } from 'react-native'; @@ -13,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { Text } from '../ui/text'; import { Button } from '../ui/button'; +import { ThemedLoading } from '../ui/ThemedLoading'; import { VideoPlayer } from '../Feed/VideoPlayer'; import { useAuth } from '~/lib/auth-provider'; import { useToast } from '~/lib/toast-provider'; @@ -340,7 +340,7 @@ export function ReplyComposer({ disabled={(!content.trim() && !media) || isUploading} > {isUploading ? ( - + ) : ( {buttonLabel} )} diff --git a/components/ui/SideMenu.tsx b/components/ui/SideMenu.tsx index 13d45db..43d1fa6 100644 --- a/components/ui/SideMenu.tsx +++ b/components/ui/SideMenu.tsx @@ -200,9 +200,10 @@ export function SideMenu({ isVisible, onClose }: SideMenuProps) { { title: "Theme", icon: "color-palette-outline" as const, - value: settings.loginBackground === 'video' ? 'Skatehive' : 'Matrix', + value: settings.theme === 'skatehive' ? 'Skatehive' : 'Matrix', onPress: () => { - updateSettings({ loginBackground: settings.loginBackground === 'video' ? 'matrix' : 'video' }); + const nextTheme = settings.theme === 'skatehive' ? 'matrix' : 'skatehive'; + updateSettings({ theme: nextTheme }); } }, { title: "Language", icon: "language-outline" as const, value: "English", hideChevron: true, onPress: () => { } }, diff --git a/components/ui/Skeletons.tsx b/components/ui/Skeletons.tsx new file mode 100644 index 0000000..4f2667f --- /dev/null +++ b/components/ui/Skeletons.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, Animated, Dimensions } from 'react-native'; +import { theme } from '~/lib/theme'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const GRID_GAP = 2; + +export const SkeletonTile = React.memo(({ size, delay = 0 }: { size: number; delay?: number }) => { + const opacity = useRef(new Animated.Value(0.3)).current; + + useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { toValue: 0.6, duration: 800, delay, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 0.3, duration: 800, useNativeDriver: true }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + }, [delay, opacity]); + + return ; +}); + +export const GridSkeleton = ({ tileSize }: { tileSize: number }) => ( + + {Array.from({ length: 12 }).map((_, i) => ( + + ))} + +); + +export const ContentSkeleton = () => ( + + + + + + + + + + + + + +); + +const styles = StyleSheet.create({ + gridContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: GRID_GAP, + justifyContent: 'flex-start', + }, + contentContainer: { + padding: theme.spacing.md, + gap: theme.spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + }, + headerText: { + flex: 1, + height: 40, + justifyContent: 'center', + }, + body: { + height: 200, + backgroundColor: theme.colors.secondaryCard, + borderRadius: theme.borderRadius.md, + }, +}); diff --git a/components/ui/ThemedLoading.tsx b/components/ui/ThemedLoading.tsx new file mode 100644 index 0000000..1c00a6f --- /dev/null +++ b/components/ui/ThemedLoading.tsx @@ -0,0 +1,94 @@ +import { ActivityIndicator, View, StyleSheet } from 'react-native'; +import { Text } from './text'; +import { useAppSettings } from '~/lib/AppSettingsContext'; +import { theme } from '~/lib/theme'; +import { MatrixRain } from './loading-effects/MatrixRain'; +import { ContentSkeleton, GridSkeleton } from './Skeletons'; + +interface ThemedLoadingProps { + size?: 'small' | 'large' | number; + type?: 'auto' | 'spinner' | 'skeleton' | 'matrix'; + color?: string; + gridTileSize?: number; + label?: string; +} + +export function ThemedLoading({ + size = 'small', + type = 'auto', + color = theme.colors.green, + gridTileSize, + label +}: ThemedLoadingProps) { + const { settings } = useAppSettings(); + + // Determine effective type based on theme if 'auto' + const effectiveType = type === 'auto' + ? (settings.theme === 'matrix' ? 'matrix' + : 'spinner') + : type; + + switch (effectiveType) { + case 'matrix': + return ( + + + {label && ( + + {label} + + )} + + ); + + case 'skeleton': + if (gridTileSize) { + return ; + } + return ; + + case 'spinner': + default: + return ( + + + {label && {label}} + + ); + } +} + +const styles = StyleSheet.create({ + spinnerContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing.md, + gap: theme.spacing.xs, + }, + matrixContainer: { + width: '100%', + flex: 1, + minHeight: 80, + overflow: 'hidden', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.1)', + }, + overlayLabel: { + position: 'absolute', + backgroundColor: 'rgba(0,0,0,0.5)', + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 8, + }, + loadingText: { + fontSize: theme.fontSizes.sm, + color: theme.colors.text, + fontFamily: theme.fonts.regular, + opacity: 0.8, + }, +}); diff --git a/components/ui/loading-effects/SkeletonBackground.tsx b/components/ui/loading-effects/SkeletonBackground.tsx new file mode 100644 index 0000000..3a94310 --- /dev/null +++ b/components/ui/loading-effects/SkeletonBackground.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, Animated, Dimensions } from 'react-native'; +import { theme } from '~/lib/theme'; + +const { width, height } = Dimensions.get('window'); + +export function SkeletonBackground() { + const opacity = useRef(new Animated.Value(0.1)).current; + + useEffect(() => { + Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { toValue: 0.2, duration: 2000, useNativeDriver: true }), + Animated.timing(opacity, { toValue: 0.1, duration: 2000, useNativeDriver: true }), + ]) + ).start(); + }, [opacity]); + + return ( + + {/* Some abstract pulsing blocks to simulate "skeleton" feel */} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: theme.colors.background, + }, + block: { + position: 'absolute', + backgroundColor: theme.colors.secondaryCard, + borderRadius: theme.borderRadius.md, + }, +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 20886d9..d1415d6 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 { useAppSettings } from '~/lib/AppSettingsContext'; import { MatrixRain } from '~/components/ui/loading-effects/MatrixRain'; const { width } = Dimensions.get('window'); @@ -13,6 +14,7 @@ interface ToastProps { } export function Toast({ message, type = 'error', onHide }: ToastProps) { + const { settings } = useAppSettings(); const translateY = React.useRef(new Animated.Value(-100)).current; const opacity = React.useRef(new Animated.Value(0)).current; @@ -82,7 +84,7 @@ export function Toast({ message, type = 'error', onHide }: ToastProps) { } ]} > - {(type === 'error' || type === 'success') && } + {settings.theme === 'matrix' && (type === 'error' || type === 'success') && } ) => void; + setUserForSettings: (username: string | null) => void; } const AppSettingsContext = createContext(undefined); export const AppSettingsProvider = ({ children }: { children: ReactNode }) => { + const [activeUser, setActiveUser] = useState(null); 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 getSettingsKey = (username: string | null) => { + return username && username !== 'SPECTATOR' + ? `app_settings_${username}` + : 'app_settings'; + }; + + const loadSettings = async (username: string | null) => { + try { + const stored = await SecureStore.getItemAsync(getSettingsKey(username)); + if (stored) { + setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(stored) }); + } else { + // If specific user settings not found, check if we should fallback, + // but for now creating fresh default settings is safer to avoid polluting from spectator + setSettings(DEFAULT_SETTINGS); } - })(); + } catch (error) { + console.error('Error loading settings:', error); + setSettings(DEFAULT_SETTINGS); + } + }; + + // Load generic settings on initial mount + useEffect(() => { + loadSettings(null); }, []); + const setUserForSettings = (username: string | null) => { + setActiveUser(username); + loadSettings(username); + }; + const updateSettings = (updates: Partial) => { setSettings(prev => { const next = { ...prev, ...updates }; - SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(next)).catch(console.error); + SecureStore.setItemAsync(getSettingsKey(activeUser), JSON.stringify(next)).catch(console.error); return next; }); }; return ( - + {children} ); diff --git a/lib/api.ts b/lib/api.ts index 96b6360..c3afb73 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -60,6 +60,50 @@ export async function getFeed(page = 1, limit = 10): Promise { } } +function normalizeFeedData(data: ApiResponse): Post[] { + if (data.success && Array.isArray(data.data)) { + return data.data.map((post: any) => { + // Map soft post fields to standard display fields + if (post.is_soft_post) { + post.displayName = post.soft_post_display_name; + post.avatarUrl = post.soft_post_avatar; + post.author = post.soft_post_author || post.author; + } else { + post.avatarUrl = `https://images.hive.blog/u/${post.author}/avatar/small`; + } + + // Map API 'votes' → dhive 'active_votes' for PostCard compatibility + if (post.votes && Array.isArray(post.votes)) { + const latestVotesMap = new Map(); + post.votes.forEach((vote: any) => { + const existingVote = latestVotesMap.get(vote.voter); + if (!existingVote || new Date(vote.timestamp) > new Date(existingVote.timestamp)) { + latestVotesMap.set(vote.voter, vote); + } + }); + post.active_votes = Array.from(latestVotesMap.values()); + } else { + post.active_votes = []; + } + + // Ensure children count is a number (for comment count display) + post.children = Number(post.children || 0); + + // Ensure json_metadata is parsed if it's a string from the API + if (typeof post.post_json_metadata === 'string') { + try { + post.json_metadata = post.post_json_metadata; + } catch (e) {} + } else if (post.post_json_metadata) { + post.json_metadata = JSON.stringify(post.post_json_metadata); + } + + return post as Post; + }); + } + return []; +} + /** * Fetches the snaps feed from the /feed endpoint (production-ready, cached, normalized) * Maps API field names to dhive-compatible names so PostCard can consume them. @@ -67,48 +111,8 @@ export async function getFeed(page = 1, limit = 10): Promise { export async function getSnapsFeed(page = 1, limit = 10): Promise { try { const response = await fetch(`${API_BASE_URL}/feed?page=${page}&limit=${limit}`); - const data: ApiResponse = await response.json(); - if (data.success && Array.isArray(data.data)) { - return data.data.map((post: any) => { - // Map soft post fields to standard display fields - if (post.is_soft_post) { - post.displayName = post.soft_post_display_name; - post.avatarUrl = post.soft_post_avatar; - post.author = post.soft_post_author || post.author; - } else { - post.avatarUrl = `https://images.hive.blog/u/${post.author}/avatar/small`; - } - - // Map API 'votes' → dhive 'active_votes' for PostCard compatibility - if (post.votes && Array.isArray(post.votes)) { - const latestVotesMap = new Map(); - post.votes.forEach((vote: any) => { - const existingVote = latestVotesMap.get(vote.voter); - if (!existingVote || new Date(vote.timestamp) > new Date(existingVote.timestamp)) { - latestVotesMap.set(vote.voter, vote); - } - }); - post.active_votes = Array.from(latestVotesMap.values()); - } else { - post.active_votes = []; - } - - // Ensure children count is a number (for comment count display) - post.children = Number(post.children || 0); - - // Ensure json_metadata is parsed if it's a string from the API - if (typeof post.post_json_metadata === 'string') { - try { - post.json_metadata = post.post_json_metadata; - } catch (e) {} - } else if (post.post_json_metadata) { - post.json_metadata = JSON.stringify(post.post_json_metadata); - } - - return post as Post; - }); - } - return []; + const data = await response.json(); + return normalizeFeedData(data); } catch (error) { console.error('Error fetching snaps feed from API:', error); throw error; // Throw to allow fallback in useSnaps @@ -116,20 +120,36 @@ export async function getSnapsFeed(page = 1, limit = 10): Promise { } /** - * Get balance - + * Fetches the Following feed + */ +export async function getFollowingFeedAPI(username: string, page = 1, limit = 10): Promise { + try { + const response = await fetch(`${API_BASE_URL}/feed/${username}/following?page=${page}&limit=${limit}`); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + const data = await response.json(); + return normalizeFeedData(data); + } catch (error) { + console.warn('Error fetching following feed from API:', error); + throw error; + } +} /** - * Fetches the Following feed + * Fetches the Trending feed */ -export async function getFollowing(username: string): Promise { +export async function getTrendingFeedAPI(page = 1, limit = 10): Promise { try { - const response = await fetch(`${API_BASE_URL}/feed/${username}/following`); - const data: ApiResponse = await response.json(); - return data.success && Array.isArray(data.data) ? data.data : []; + const response = await fetch(`${API_BASE_URL}/feed/trending?page=${page}&limit=${limit}`); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + const data = await response.json(); + return normalizeFeedData(data); } catch (error) { - console.error('Error fetching trending:', error); - return []; + console.warn('Error fetching trending feed from API:', error); + throw error; } } diff --git a/lib/auth-provider.tsx b/lib/auth-provider.tsx index 8166231..f5b4e52 100644 --- a/lib/auth-provider.tsx +++ b/lib/auth-provider.tsx @@ -100,7 +100,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [blacklistedList, setBlacklistedList] = useState([]); const [blockedList, setBlockedList] = useState([]); const inactivityTimer = useRef | null>(null); - const { settings } = useAppSettings(); + const { settings, setUserForSettings } = useAppSettings(); + + // Sync active user to AppSettings Context + useEffect(() => { + setUserForSettings(username); + }, [username, setUserForSettings]); // Delete a single stored user and update state const removeStoredUser = async (usernameToRemove: string) => { @@ -309,30 +314,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Check if a user is already logged in (restore session) const checkCurrentUser = async () => { try { - // Robust check: Verify session duration from storage to avoid race conditions - // with AppSettingsContext loading. - const storedSettingsStr = await SecureStore.getItemAsync('app_settings'); + const storedSession = await SecureStore.getItemAsync(SESSION_KEY); 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: Omit & { expiryAt: number } = JSON.parse(storedSession); + // Check user specifics settings to see if AutoLock was enabled for them + const userSettingsStr = await SecureStore.getItemAsync(`app_settings_${parsed.username}`); + if (userSettingsStr) { + const storedSettings = JSON.parse(userSettingsStr); + if (storedSettings.sessionDuration === 0) { + isAutoLock = true; + } + } else { + // fallback to global settings + const storedSettingsStr = await SecureStore.getItemAsync('app_settings'); + if (storedSettingsStr) { + const storedSettings = JSON.parse(storedSettingsStr); + if (storedSettings.sessionDuration === 0) { + isAutoLock = true; + } + } + } + + if (isAutoLock) { + await SecureStore.deleteItemAsync(SESSION_KEY); + setUsername(null); + setIsAuthenticated(false); + setSession(null); + return; + } + // Check if session has expired if (parsed.expiryAt > Date.now()) { // Rehydrate the decryptedKey from encrypted storage diff --git a/lib/config/AppConfig.ts b/lib/config/AppConfig.ts index 4e90c92..d2e19a2 100644 --- a/lib/config/AppConfig.ts +++ b/lib/config/AppConfig.ts @@ -1,18 +1,13 @@ export interface AppSettings { /** - * The type of loading effect to show (e.g., 'skeleton', 'spinner'). + * The application theme. */ - loadingEffect: 'skeleton' | 'spinner' | 'none'; - /** - * The type of background to show on the login screen. - */ - loginBackgroundType: 'matrix' | 'video' | 'image'; + theme: 'matrix' | 'skatehive'; } /** * Default application configuration. */ export const AppConfig: AppSettings = { - loadingEffect: 'skeleton', - loginBackgroundType: 'matrix', + theme: 'skatehive', }; diff --git a/lib/config/SnapConfig.ts b/lib/config/SnapConfig.ts index 40b0c26..c2445b2 100644 --- a/lib/config/SnapConfig.ts +++ b/lib/config/SnapConfig.ts @@ -32,7 +32,7 @@ export interface SnapSettings { */ export const SnapConfig: SnapSettings = Platform.select({ ios: { - useApi: true, + useApi: false, // Reverted iOS to use native DHive fallback for better stability verifyDeletion: true, pageSize: 10, fetchLimit: 40, diff --git a/lib/config/VideoConfig.ts b/lib/config/VideoConfig.ts index 28c5041..ecd3d42 100644 --- a/lib/config/VideoConfig.ts +++ b/lib/config/VideoConfig.ts @@ -65,8 +65,8 @@ export const VideoConfig: VideoSettings = Platform.select({ android: { ...commonSettings, preferredRenderer: 'webview', // Fallback to WebView for Android to avoid reported crashes - enablePrefetch: true, // User wants to try prefetching on Android too - maxConcurrentPlayers: 2, // Be more conservative with memory on Android + enablePrefetch: false, // Disabled prefetching aggressively on Android + maxConcurrentPlayers: 1, // Be as conservative as possible with memory on Android }, default: { ...commonSettings, diff --git a/lib/constants.ts b/lib/constants.ts index 9739895..f4abff5 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -9,7 +9,7 @@ export const APP_NAME="Skatehive"; export const STORED_USERS_KEY = 'myc_users'; export const API_BASE_URL = ENV_API_BASE_URL || 'https://api.skatehive.app/api/v2'; export const LEADERBOARD_API_URL = ENV_LEADERBOARD_API_URL || 'https://api.skatehive.app/api/v2/leaderboard'; -export const LOGIN_BACKGROUND_TYPE = AppConfig.loginBackgroundType; +export const DEFAULT_THEME = AppConfig.theme; export const API_SEARCH_URL = ENV_API_SEARCH_URL || API_BASE_URL; export const NAV_THEME = { diff --git a/lib/hooks/useSearch.ts b/lib/hooks/useSearch.ts index 8a0f9b5..2473059 100644 --- a/lib/hooks/useSearch.ts +++ b/lib/hooks/useSearch.ts @@ -30,9 +30,9 @@ export function useSearch(query: string, type: SearchType = 'all', time: TimeFil ( (type === 'all' || type === 'snaps') && snapsQuery.isLoading); return { - users: usersQuery.data?.success ? usersQuery.data.data.users : [], + users: (usersQuery.data?.success && usersQuery.data.data?.users) ? usersQuery.data.data.users : [], isUsersLoading: usersQuery.isLoading, - snaps: snapsQuery.data?.pages.flatMap(page => page.data?.snaps || []) || [], + snaps: snapsQuery.data?.pages.flatMap(page => page?.data?.snaps || []) || [], isSnapsLoading: snapsQuery.isLoading, isSnapsFetchingNextPage: snapsQuery.isFetchingNextPage, loadMoreSnaps: snapsQuery.fetchNextPage, diff --git a/lib/hooks/useSnaps.ts b/lib/hooks/useSnaps.ts index 23408bd..bd28366 100644 --- a/lib/hooks/useSnaps.ts +++ b/lib/hooks/useSnaps.ts @@ -1,11 +1,15 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { getSnapsContainers, getContentReplies, ExtendedComment, SNAPS_CONTAINER_AUTHOR, SNAPS_PAGE_MIN_SIZE, COMMUNITY_TAG, getDiscussions } 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 { SnapConfig } from '../config/SnapConfig'; import { FeedFilterType } from '../FeedFilterContext'; import { useAuth } from '../auth-provider'; -import { getSnapsFeed } from '../api'; - +import { getSnapsFeed, getFollowingFeedAPI, getTrendingFeedAPI } from '../api'; interface LastContainerInfo { permlink: string; date: string; @@ -49,19 +53,18 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n // Fetch comments with progressive loading async function getMoreSnaps(): Promise { - // MOCKED: For now, we only show the Curated feed regardless of filter - const effectiveFilter = 'Curated'; - - if (effectiveFilter === 'Curated') { + const effectiveFilter = filter; + + if (effectiveFilter === 'Curated' || effectiveFilter === 'Recent' || effectiveFilter === 'Skatehive') { try { const currentPage = apiPageRef.current; const apiSnaps = await getSnapsFeed(currentPage, SnapConfig.pageSize); - + if (apiSnaps && apiSnaps.length > 0) { apiPageRef.current += 1; const blockedSet = new Set(blockedList.map(u => u.toLowerCase())); const safelyFilteredComments = apiSnaps.filter(c => !blockedSet.has(c.author.toLowerCase())) as unknown as ExtendedComment[]; - + safelyFilteredComments.forEach(c => fetchedPermlinksRef.current.add(c.permlink)); return safelyFilteredComments; } else if (apiSnaps && apiSnaps.length === 0 && currentPage > 1) { @@ -81,43 +84,43 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n 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, }); - + 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); - + // Filter by blocked users const blockedSet = new Set(blockedList.map(u => u.toLowerCase())); const safelyFilteredComments = filteredComments.filter(c => !blockedSet.has(c.author.toLowerCase())); - + allPermlinks.add(resultItem.permlink); safelyFilteredComments.forEach(c => allPermlinks.add(c.permlink)); allFilteredComments.push(...safelyFilteredComments); permlink = resultItem.permlink; date = resultItem.created; - + if (allFilteredComments.length >= pageSize) break; } } catch (error) { @@ -125,13 +128,43 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n 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 + // Try Skatehive API if enabled + if (SnapConfig.useApi) { + try { + const currentPage = apiPageRef.current; + let apiSnaps: any[] = []; + + if (filter === 'Following') { + if (!username || username === 'SPECTATOR') return []; + apiSnaps = await getFollowingFeedAPI(username, currentPage, SnapConfig.pageSize); + } else if (filter === 'Trending') { + apiSnaps = await getTrendingFeedAPI(currentPage, SnapConfig.pageSize); + } + + if (apiSnaps && apiSnaps.length > 0) { + apiPageRef.current += 1; + const blockedSet = new Set(blockedList.map(u => u.toLowerCase())); + const safelyFilteredComments = apiSnaps.filter(c => !blockedSet.has(c.author.toLowerCase())) as unknown as ExtendedComment[]; + + safelyFilteredComments.forEach(c => fetchedPermlinksRef.current.add(c.permlink)); + return safelyFilteredComments; + } else if (apiSnaps && apiSnaps.length === 0 && currentPage > 1) { + // Reached the end of the API feed + return []; + } + } catch (error) { + console.warn(`Failed to fetch ${filter} feed from skatehive-api, falling back to dhive natively:`, error); + } + } + + // --- NATIVE DHIVE FALLBACK --- + // Use getDiscussions for other filters ('Following', 'Trending') let type: 'created' | 'trending' | 'hot' | 'feed' = 'created'; let tag = COMMUNITY_TAG; @@ -143,23 +176,31 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n } const lastPost = comments.length > 0 ? comments[comments.length - 1] : null; - + const results = await getDiscussions(type, { tag, - limit: SnapConfig.pageSize, + limit: SnapConfig.pageSize * 2, // Fetch extra in case many aren't from Skatehive start_author: lastPost?.author, start_permlink: lastPost?.permlink }); // Filter out blocked users and duplicates const blockedSet = new Set(blockedList.map(u => u.toLowerCase())); - const filteredResults = results.filter(r => - !blockedSet.has(r.author.toLowerCase()) && - !fetchedPermlinksRef.current.has(r.permlink) - ); - + const filteredResults = results.filter(r => { + // Must match community tag (category or tags metadata) + const inCommunity = r.category === COMMUNITY_TAG || + (r.json_metadata && typeof r.json_metadata === 'object' && + (r.json_metadata as any).tags && (r.json_metadata as any).tags.includes(COMMUNITY_TAG)) || + (r.json_metadata && typeof r.json_metadata === 'string' && + r.json_metadata.includes(COMMUNITY_TAG)); + + return inCommunity && + !blockedSet.has(r.author.toLowerCase()) && + !fetchedPermlinksRef.current.has(r.permlink); + }); + filteredResults.forEach(r => fetchedPermlinksRef.current.add(r.permlink)); - + return filteredResults as unknown as ExtendedComment[]; } } diff --git a/lib/notifications-context.tsx b/lib/notifications-context.tsx index ccd2c3c..85ca555 100644 --- a/lib/notifications-context.tsx +++ b/lib/notifications-context.tsx @@ -18,6 +18,7 @@ interface NotificationProviderProps { export function NotificationProvider({ children }: NotificationProviderProps) { const { username } = useAuth(); const [badgeCount, setBadgeCount] = useState(0); + const [lastMarkedReadTimestamp, setLastMarkedReadTimestamp] = useState(0); const updateBadgeCount = useCallback(async () => { if (!username || username === 'SPECTATOR') { @@ -25,6 +26,12 @@ export function NotificationProvider({ children }: NotificationProviderProps) { return; } + // Ignore API fetches for 20 seconds after marking as read to allow Hive indexers to catch up + // This prevents the badge from popping back up with old unread notifications before the blockchain clears them. + if (Date.now() - lastMarkedReadTimestamp < 20000) { + return; + } + try { const newNotifications = await fetchNewNotifications(username); setBadgeCount(newNotifications.length); @@ -32,20 +39,17 @@ export function NotificationProvider({ children }: NotificationProviderProps) { console.error('Error fetching notification badge count:', error); // Don't reset count on error to avoid flickering } - }, [username]); + }, [username, lastMarkedReadTimestamp]); const clearBadge = useCallback(() => { setBadgeCount(0); }, []); const onNotificationsMarkedAsRead = useCallback(() => { - // Immediately clear the badge + // Immediately clear the badge and block API updates for a few seconds setBadgeCount(0); - // Then refresh to make sure it's accurate - setTimeout(() => { - updateBadgeCount(); - }, 1000); // Wait 1 second for the mark as read operation to complete on blockchain - }, [updateBadgeCount]); + setLastMarkedReadTimestamp(Date.now()); + }, []); // Update badge count on mount and when username changes useEffect(() => { diff --git a/scripts/checkFeed.js b/scripts/checkFeed.js new file mode 100644 index 0000000..dc4b134 --- /dev/null +++ b/scripts/checkFeed.js @@ -0,0 +1,8 @@ +import { Client } from '@hiveio/dhive'; +const client = new Client('https://api.hive.blog'); +async function run() { + const result = await client.database.call('get_discussions_by_feed', [{tag: 'vaipraonde', limit: 10}]); + console.log('Results length:', result.length); + result.forEach(r => console.log('Author:', r.author, 'Category:', r.category, 'Title:', r.title)); +} +run().catch(console.error); diff --git a/scripts/test-feed-perf.js b/scripts/test-feed-perf.js new file mode 100644 index 0000000..e337c1f --- /dev/null +++ b/scripts/test-feed-perf.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +const http = require('http'); + +const options = { + hostname: 'localhost', + port: 3000, + path: '/api/v2/feed/vaipraonde/following', + method: 'GET' +}; + +console.log('Testing following feed performance...'); +const start = Date.now(); + +const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + const time = Date.now() - start; + console.log(`\nStatus Code: ${res.statusCode}`); + console.log(`Time taken: ${time}ms\n`); + + try { + const json = JSON.parse(data); + if (json.success) { + console.log(`✅ Success! Fetched ${json.data.length} items`); + if (json.data.length > 0) { + console.log('First item author:', json.data[0].author); + console.log('First item permlink:', json.data[0].permlink); + } + } else { + console.log('❌ API reported failure:', json); + } + } catch (e) { + console.log('❌ Failed to parse response:', data.substring(0, 500)); + } + }); +}); + +req.on('error', (error) => { + console.error('Request error:', error.message); + if (error.code === 'ECONNREFUSED' && options.port === 3000) { + console.log('Trying port 3001...'); + options.port = 3001; + const retryReq = http.request(options, /* same logic */); + retryReq.end(); + } +}); + +req.end();