From 2542885a8a5e52ab2a431c7946072902fbe64ddd Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:44:45 -0300 Subject: [PATCH 01/13] fix: video configuration and rendering patches! Android will continue to aggressively unmount players when they leave the screen to prevent Out-Of-Memory (OOM) crashes, and will use WebView fallbacks as needed via your VideoConfig. This retains all your memory leak fixes. iOS will now keep the native AVPlayer buffered and mounted regardless of isInView constraints (imitating the main branch), ensuring smooth, instantaneous video playback without the "fail to play" issues caused by rapid mounting/unmounting. I've also reverted useApi to false in SnapConfig.ts strictly for iOS so it uses the native stable DHive fetching mechanism like the main branch. --- app/(tabs)/videos.tsx | 5 +++-- components/Feed/VideoWithAutoplay.tsx | 4 ++-- lib/config/SnapConfig.ts | 2 +- lib/config/VideoConfig.ts | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/(tabs)/videos.tsx b/app/(tabs)/videos.tsx index c62dac4..e01a217 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"; @@ -354,8 +355,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 */} + {Platform.OS === 'ios' || isNearby ? ( - {isInView && ( + {(Platform.OS === 'ios' || isInView) && ( Date: Fri, 27 Mar 2026 02:47:43 -0300 Subject: [PATCH 02/13] feat: Port thumbnails to VideoWithAutoplay from main VideoWithAutoplay.tsx: Reintroduced the thumbnailUrl prop and added isPlaying tracking. Overlay UI: Merged the static Image poster and ActivityIndicator spinner overlays identically to how it functioned in main (only hiding it once onPlaybackStarted fires). MediaPreview.tsx: Updated its props and ensured thumbnailUrl cascades correctly into when rendering a video. --- components/Feed/MediaPreview.tsx | 3 ++ components/Feed/VideoWithAutoplay.tsx | 56 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) 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/VideoWithAutoplay.tsx b/components/Feed/VideoWithAutoplay.tsx index 4fc17e3..0f83d38 100644 --- a/components/Feed/VideoWithAutoplay.tsx +++ b/components/Feed/VideoWithAutoplay.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import { View, Pressable, StyleSheet, ViewStyle, Platform } from 'react-native'; +import { View, Pressable, StyleSheet, ViewStyle, Platform, ActivityIndicator } from 'react-native'; +import { Image } from 'expo-image'; import { FontAwesome } from '@expo/vector-icons'; import { useIsFocused } from '@react-navigation/native'; import { VideoPlayer } from './VideoPlayer'; @@ -9,17 +10,20 @@ import { theme } from '../../lib/theme'; 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 }); @@ -43,6 +47,7 @@ export function VideoWithAutoplay({ setIsPlaying(true)} /> )} @@ -52,6 +57,35 @@ export function VideoWithAutoplay({ )} + + {/* Thumbnail overlay until video plays */} + {!isPlaying && ( + <> + {thumbnailUrl ? ( + + + {(!requireInteraction || hasInteracted) && ( + + + + )} + + ) : ( + + {(!requireInteraction || hasInteracted) ? ( + + ) : ( + + )} + + )} + + )} ); @@ -68,6 +102,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, From 1a901732e7e655a531fcb2ee4cd28066974c8688 Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:37:17 -0300 Subject: [PATCH 03/13] fix: Unifying Loading Indicators across all app --- app/(tabs)/create.tsx | 16 ++-- app/(tabs)/profile.tsx | 78 +++++---------- app/(tabs)/search.tsx | 6 +- app/(tabs)/videos.tsx | 10 +- app/index.tsx | 2 +- components/Feed/Conversation.tsx | 4 +- components/Feed/ConversationDrawer.tsx | 6 +- components/Feed/ConversationReply.tsx | 6 +- components/Feed/EmbedPlayer.tsx | 7 +- components/Feed/Feed.tsx | 5 +- components/Feed/PostCard.tsx | 19 ++-- components/Feed/VideoWithAutoplay.tsx | 7 +- components/Profile/EditProfileModal.tsx | 6 +- components/Profile/FollowersModal.tsx | 6 +- components/markdown/embeds/BaseVideoEmbed.tsx | 5 +- components/markdown/embeds/InstagramEmbed.tsx | 5 +- components/markdown/embeds/SnapshotEmbed.tsx | 5 +- components/markdown/embeds/ZoraEmbed.tsx | 5 +- .../notifications/NotificationsScreen.tsx | 29 ++---- components/ui/LoadingScreen.tsx | 24 ++++- components/ui/RecentMediaGallery.tsx | 6 +- components/ui/ReplyComposer.tsx | 4 +- components/ui/SideMenu.tsx | 5 +- components/ui/Skeletons.tsx | 75 +++++++++++++++ components/ui/ThemedLoading.tsx | 94 +++++++++++++++++++ .../ui/loading-effects/SkeletonBackground.tsx | 43 +++++++++ components/ui/toast.tsx | 4 +- lib/AppSettingsContext.tsx | 4 +- lib/config/AppConfig.ts | 11 +-- lib/constants.ts | 2 +- 30 files changed, 346 insertions(+), 153 deletions(-) create mode 100644 components/ui/Skeletons.tsx create mode 100644 components/ui/ThemedLoading.tsx create mode 100644 components/ui/loading-effects/SkeletonBackground.tsx 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 e01a217..b309d7d 100644 --- a/app/(tabs)/videos.tsx +++ b/app/(tabs)/videos.tsx @@ -28,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"); @@ -382,7 +382,7 @@ export default function VideosScreen() { {/* Loading indicator — only while video is actively buffering */} {isActive && !isVideoPlaying && ( - + )} @@ -431,7 +431,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/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 0f83d38..8b423b7 100644 --- a/components/Feed/VideoWithAutoplay.tsx +++ b/components/Feed/VideoWithAutoplay.tsx @@ -1,11 +1,12 @@ import React, { useState } from 'react'; -import { View, Pressable, StyleSheet, ViewStyle, Platform, ActivityIndicator } 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; @@ -71,14 +72,14 @@ export function VideoWithAutoplay({ /> {(!requireInteraction || hasInteracted) && ( - + )} ) : ( {(!requireInteraction || hasInteracted) ? ( - + ) : ( )} 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') && } Date: Fri, 27 Mar 2026 19:58:41 -0300 Subject: [PATCH 04/13] fix: Video Release Error: Forced VideoPlayer instances to unmount gracefully during the logout sequence before the navigation transitions resolve. --- app/(tabs)/videos.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/(tabs)/videos.tsx b/app/(tabs)/videos.tsx index b309d7d..9da0b86 100644 --- a/app/(tabs)/videos.tsx +++ b/app/(tabs)/videos.tsx @@ -68,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) { @@ -133,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(() => { @@ -356,7 +367,7 @@ export default function VideosScreen() { onPress={handleTap} > {/* Only mount VideoPlayer for current and adjacent items on Android, but keep iOS mounted for stable native playback */} - {Platform.OS === 'ios' || isNearby ? ( + {!isLoggingOut && (Platform.OS === 'ios' || isNearby) ? ( Date: Fri, 27 Mar 2026 19:58:52 -0300 Subject: [PATCH 05/13] fix: Leaderboard Bottom Spacing: Refactored the padding from the ScrollView container style to its contentContainerStyle and increased it to 130px. --- components/Leaderboard/leaderboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From 242443ace6125af0f55b7287cdc32a63d0c97fb5 Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:59:00 -0300 Subject: [PATCH 06/13] fix: Search Component TypeError: Handled cases where the search API returns a timeout error instead of an array. --- lib/hooks/useSearch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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, From a65bac3a3098104c0075783d565d629011365157 Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:12:47 -0300 Subject: [PATCH 07/13] fix: notification badge count bug! --- lib/notifications-context.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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(() => { From e9937cd6459ce6ca88b678a71aaddad87027ed9d Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:19:20 -0300 Subject: [PATCH 08/13] feat: Implementing Per-User App Settings --- lib/AppSettingsContext.tsx | 46 +++++++++++++++++++++++---------- lib/auth-provider.tsx | 53 ++++++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/lib/AppSettingsContext.tsx b/lib/AppSettingsContext.tsx index af6e802..90afdeb 100644 --- a/lib/AppSettingsContext.tsx +++ b/lib/AppSettingsContext.tsx @@ -33,37 +33,57 @@ const DEFAULT_SETTINGS: AppSettings = { interface AppSettingsContextType { settings: AppSettings; updateSettings: (updates: Partial) => 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/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 From c955e6397ad8c94a8453e7d95a14fd452f324cbd Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:39:45 -0300 Subject: [PATCH 09/13] feat: getFollowingFeedAPI Here is what has been implemented: Feed Normalization: I created a reusable function (normalizeFeedData) in lib/api.ts that takes the raw API response and properly converts the soft posts, formatting, stringified JSON, and active_votes so that PostCard can read them smoothly. This gives Trending and Following exactly the same shape and treatment as the SnapsFeed. New endpoints added: getFollowingFeedAPI utilizes /feed/${username}/following and getTrendingFeedAPI utilizes /trending. Toggle Logic: Inside useSnaps.ts, it now checks SnapConfig.useApi. If it's true, the Following and Trending feeds will hit the new API endpoints. It successfully tracks the pagination with apiPageRef. Dhive Fallback: If useApi is false, or if the Skatehive API fails unexpectedly, it has a built-in catch block that seamlessly falls back to the native dhive implementation (getDiscussions mapped by feed type and COMMUNITY_TAG, respectively). --- app/(tabs)/_layout.tsx | 73 +++++++++++++------------- lib/api.ts | 116 +++++++++++++++++++++++------------------ lib/hooks/useSnaps.ts | 83 ++++++++++++++++++++--------- 3 files changed, 161 insertions(+), 111 deletions(-) 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/lib/api.ts b/lib/api.ts index 96b6360..2ee65b8 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,30 @@ 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}`); + const data = await response.json(); + return normalizeFeedData(data); + } catch (error) { + console.error('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}/trending?page=${page}&limit=${limit}`); + const data = await response.json(); + return normalizeFeedData(data); } catch (error) { - console.error('Error fetching trending:', error); - return []; + console.error('Error fetching trending feed from API:', error); + throw error; } } diff --git a/lib/hooks/useSnaps.ts b/lib/hooks/useSnaps.ts index 23408bd..ac990ea 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,7 +176,7 @@ 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, @@ -153,13 +186,13 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n // 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()) && + const filteredResults = results.filter(r => + !blockedSet.has(r.author.toLowerCase()) && !fetchedPermlinksRef.current.has(r.permlink) ); - + filteredResults.forEach(r => fetchedPermlinksRef.current.add(r.permlink)); - + return filteredResults as unknown as ExtendedComment[]; } } From edb83830ca2a0712f414ba302c5fd4ed2e7cdbf2 Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:48:01 -0300 Subject: [PATCH 10/13] fix: Incorrect Trending URL: I realized that the trending API endpoint should be /api/v2/feed/trending, but my previous code was hitting /api/v2/trending, which resulted in a 404 HTML page. --- lib/api.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/api.ts b/lib/api.ts index 2ee65b8..c3afb73 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -125,10 +125,13 @@ export async function getSnapsFeed(page = 1, limit = 10): Promise { 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.error('Error fetching following feed from API:', error); + console.warn('Error fetching following feed from API:', error); throw error; } } @@ -138,11 +141,14 @@ export async function getFollowingFeedAPI(username: string, page = 1, limit = 10 */ export async function getTrendingFeedAPI(page = 1, limit = 10): Promise { try { - const response = await fetch(`${API_BASE_URL}/trending?page=${page}&limit=${limit}`); + 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 feed from API:', error); + console.warn('Error fetching trending feed from API:', error); throw error; } } From aafd45438849bfaf7cd4b8413cba0582474f4ea7 Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:06:14 -0300 Subject: [PATCH 11/13] fix: dded a strict community filter to the dhive fallback inside useSnaps.ts! Now, when the app gathers the Following feed, it manually iterates through everything and aggressively strips out any post that is not tagged with hive-173115 or not posted directly into the Skatehive community. --- lib/hooks/useSnaps.ts | 18 +++++++++++++----- scripts/checkFeed.js | 8 ++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 scripts/checkFeed.js diff --git a/lib/hooks/useSnaps.ts b/lib/hooks/useSnaps.ts index ac990ea..bd28366 100644 --- a/lib/hooks/useSnaps.ts +++ b/lib/hooks/useSnaps.ts @@ -179,17 +179,25 @@ export function useSnaps(filter: FeedFilterType = 'Recent', username: string | n 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)); 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); From 9e798ca9f627c52d215b0f76be08415ff569a00b Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:11:27 -0300 Subject: [PATCH 12/13] test: feed performance --- scripts/test-feed-perf.js | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/test-feed-perf.js 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(); From 4b8481ad52cf94a725c2199f4e2e95284ca54b2b Mon Sep 17 00:00:00 2001 From: rferrari <495887+rferrari@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:55:08 -0300 Subject: [PATCH 13/13] fix: the bug where the comments drawer displayed incorrect information when switching between videos. --- components/Feed/ConversationDrawer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/Feed/ConversationDrawer.tsx b/components/Feed/ConversationDrawer.tsx index 6168518..a79b0ca 100644 --- a/components/Feed/ConversationDrawer.tsx +++ b/components/Feed/ConversationDrawer.tsx @@ -74,6 +74,10 @@ export function ConversationDrawer({ } }, [isVisible, initialAuthor, initialPermlink, post]); + useEffect(() => { + setPost(initialPost); + }, [initialAuthor, initialPermlink, initialPost]); + const [optimisticReplies, setOptimisticReplies] = useState([]); useEffect(() => {