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();