diff --git a/backend/api/internal/database/dev.sqlite3 b/backend/api/internal/database/dev.sqlite3 index 159e90a..751d7cf 100644 Binary files a/backend/api/internal/database/dev.sqlite3 and b/backend/api/internal/database/dev.sqlite3 differ diff --git a/backend/api/internal/database/dev.sqlite3-shm b/backend/api/internal/database/dev.sqlite3-shm index ee5bb56..f9612d8 100644 Binary files a/backend/api/internal/database/dev.sqlite3-shm and b/backend/api/internal/database/dev.sqlite3-shm differ diff --git a/backend/api/internal/database/dev.sqlite3-wal b/backend/api/internal/database/dev.sqlite3-wal index 3614388..7f67ba1 100644 Binary files a/backend/api/internal/database/dev.sqlite3-wal and b/backend/api/internal/database/dev.sqlite3-wal differ diff --git a/backend/uploads/0d0fdc6d5157b7829e33845e.heic b/backend/uploads/0d0fdc6d5157b7829e33845e.heic deleted file mode 100644 index d0d4cb7..0000000 Binary files a/backend/uploads/0d0fdc6d5157b7829e33845e.heic and /dev/null differ diff --git a/backend/uploads/1aba36ceb27965e859b99c66.jpg b/backend/uploads/1aba36ceb27965e859b99c66.jpg deleted file mode 100644 index 9fe59a8..0000000 Binary files a/backend/uploads/1aba36ceb27965e859b99c66.jpg and /dev/null differ diff --git a/backend/uploads/35d075cb5485c1a84d4fff10.jpg b/backend/uploads/35d075cb5485c1a84d4fff10.jpg deleted file mode 100644 index d9941d4..0000000 Binary files a/backend/uploads/35d075cb5485c1a84d4fff10.jpg and /dev/null differ diff --git a/backend/uploads/3962c4807f7f91ddae073b55.jpg b/backend/uploads/3962c4807f7f91ddae073b55.jpg deleted file mode 100644 index 355cc6a..0000000 Binary files a/backend/uploads/3962c4807f7f91ddae073b55.jpg and /dev/null differ diff --git a/backend/uploads/4a94a0cb50f3b387b4de4ef5.m4a b/backend/uploads/4a94a0cb50f3b387b4de4ef5.m4a deleted file mode 100644 index 3fbfe19..0000000 Binary files a/backend/uploads/4a94a0cb50f3b387b4de4ef5.m4a and /dev/null differ diff --git a/backend/uploads/4c4871d3a5dcd67526be0aa5.jpg b/backend/uploads/4c4871d3a5dcd67526be0aa5.jpg new file mode 100644 index 0000000..9f558af Binary files /dev/null and b/backend/uploads/4c4871d3a5dcd67526be0aa5.jpg differ diff --git a/backend/uploads/4effe678d837e81ac1c706db.mp4 b/backend/uploads/4effe678d837e81ac1c706db.mp4 deleted file mode 100644 index 4c1dd73..0000000 Binary files a/backend/uploads/4effe678d837e81ac1c706db.mp4 and /dev/null differ diff --git a/backend/uploads/5c389cb9e0973a5a8f5d22c3.jpg b/backend/uploads/5c389cb9e0973a5a8f5d22c3.jpg deleted file mode 100644 index 97a0826..0000000 Binary files a/backend/uploads/5c389cb9e0973a5a8f5d22c3.jpg and /dev/null differ diff --git a/backend/uploads/6df6507f3d9edb87a3b17174.jpg b/backend/uploads/6df6507f3d9edb87a3b17174.jpg deleted file mode 100644 index 6c857e7..0000000 Binary files a/backend/uploads/6df6507f3d9edb87a3b17174.jpg and /dev/null differ diff --git a/backend/uploads/82c8595f5df641cb0375e499.heic b/backend/uploads/82c8595f5df641cb0375e499.heic deleted file mode 100644 index 4fc48f1..0000000 Binary files a/backend/uploads/82c8595f5df641cb0375e499.heic and /dev/null differ diff --git a/backend/uploads/87a2056f6b58258e7ddf5be9.heic b/backend/uploads/87a2056f6b58258e7ddf5be9.heic deleted file mode 100644 index beacb7b..0000000 Binary files a/backend/uploads/87a2056f6b58258e7ddf5be9.heic and /dev/null differ diff --git a/backend/uploads/8d920369f17377779f7688f3.heic b/backend/uploads/8d920369f17377779f7688f3.heic new file mode 100644 index 0000000..d58817c Binary files /dev/null and b/backend/uploads/8d920369f17377779f7688f3.heic differ diff --git a/backend/uploads/90f2a658c3d1bd7dc282aeb9.jpg b/backend/uploads/90f2a658c3d1bd7dc282aeb9.jpg deleted file mode 100644 index e35982b..0000000 Binary files a/backend/uploads/90f2a658c3d1bd7dc282aeb9.jpg and /dev/null differ diff --git a/backend/uploads/9d83afc74c7bacd66483004f.pdf b/backend/uploads/9d83afc74c7bacd66483004f.pdf deleted file mode 100644 index eb31fcc..0000000 Binary files a/backend/uploads/9d83afc74c7bacd66483004f.pdf and /dev/null differ diff --git a/backend/uploads/9fc9957ad638d7d8a6118bba.heic b/backend/uploads/9fc9957ad638d7d8a6118bba.heic deleted file mode 100644 index 69227e2..0000000 Binary files a/backend/uploads/9fc9957ad638d7d8a6118bba.heic and /dev/null differ diff --git a/backend/uploads/aafa996433304a7f7f887777.heic b/backend/uploads/aafa996433304a7f7f887777.heic new file mode 100644 index 0000000..a03233c Binary files /dev/null and b/backend/uploads/aafa996433304a7f7f887777.heic differ diff --git a/backend/uploads/b035fdfcce117301b0fe0dc5.heic b/backend/uploads/b035fdfcce117301b0fe0dc5.heic deleted file mode 100644 index 69227e2..0000000 Binary files a/backend/uploads/b035fdfcce117301b0fe0dc5.heic and /dev/null differ diff --git a/backend/uploads/ba7385d7b7d2b242943e3826.jpg b/backend/uploads/ba7385d7b7d2b242943e3826.jpg deleted file mode 100644 index 00a0028..0000000 Binary files a/backend/uploads/ba7385d7b7d2b242943e3826.jpg and /dev/null differ diff --git a/backend/uploads/c00c03c4cc9ebc466d322d39.m4a b/backend/uploads/c00c03c4cc9ebc466d322d39.m4a deleted file mode 100644 index 3fbfe19..0000000 Binary files a/backend/uploads/c00c03c4cc9ebc466d322d39.m4a and /dev/null differ diff --git a/backend/uploads/c2aa90cc4f38e600d0b3afd4.jpg b/backend/uploads/c2aa90cc4f38e600d0b3afd4.jpg deleted file mode 100644 index 98ac39d..0000000 Binary files a/backend/uploads/c2aa90cc4f38e600d0b3afd4.jpg and /dev/null differ diff --git a/backend/uploads/c870ac81e1e343d783626684.heic b/backend/uploads/c870ac81e1e343d783626684.heic deleted file mode 100644 index f7bb6bd..0000000 Binary files a/backend/uploads/c870ac81e1e343d783626684.heic and /dev/null differ diff --git a/backend/uploads/36dc53ff52cd0cd9b7a1bae8.jpg b/backend/uploads/cd207f0723013061fc1b1fb9.jpg similarity index 100% rename from backend/uploads/36dc53ff52cd0cd9b7a1bae8.jpg rename to backend/uploads/cd207f0723013061fc1b1fb9.jpg diff --git a/backend/uploads/d3628e395171f33b425db871.jpg b/backend/uploads/d3628e395171f33b425db871.jpg new file mode 100644 index 0000000..5f03f7e Binary files /dev/null and b/backend/uploads/d3628e395171f33b425db871.jpg differ diff --git a/backend/uploads/d90ff3877bb16a401db253c9.jpg b/backend/uploads/d90ff3877bb16a401db253c9.jpg deleted file mode 100644 index 96826f9..0000000 Binary files a/backend/uploads/d90ff3877bb16a401db253c9.jpg and /dev/null differ diff --git a/backend/uploads/dcb1f9fd4ba4756fb91370f8.jpg b/backend/uploads/dcb1f9fd4ba4756fb91370f8.jpg deleted file mode 100644 index 4f60dea..0000000 Binary files a/backend/uploads/dcb1f9fd4ba4756fb91370f8.jpg and /dev/null differ diff --git a/backend/uploads/e4a6d08bdb895a2518bbf088.heic b/backend/uploads/e4a6d08bdb895a2518bbf088.heic deleted file mode 100644 index d0d4cb7..0000000 Binary files a/backend/uploads/e4a6d08bdb895a2518bbf088.heic and /dev/null differ diff --git a/backend/uploads/f0443e22c7a7510261e8529a.jpg b/backend/uploads/f0443e22c7a7510261e8529a.jpg new file mode 100644 index 0000000..0d64a27 Binary files /dev/null and b/backend/uploads/f0443e22c7a7510261e8529a.jpg differ diff --git a/frontend/app/(tabs)/_layout.tsx b/frontend/app/(tabs)/_layout.tsx index 4565822..4e06742 100644 --- a/frontend/app/(tabs)/_layout.tsx +++ b/frontend/app/(tabs)/_layout.tsx @@ -14,6 +14,7 @@ export default function TabLayout() { return ( { if (motion.prefersReducedMotion) { @@ -450,15 +452,18 @@ export default function ExploreScreen() { - } contentContainerStyle={[ @@ -536,6 +541,7 @@ export default function ExploreScreen() { })} + {showSearchResults ? ( @@ -579,7 +585,7 @@ export default function ExploreScreen() { ]} > {person.picture ? ( - @@ -780,7 +786,7 @@ export default function ExploreScreen() { ]} > {person.picture ? ( - @@ -876,10 +882,10 @@ export default function ExploreScreen() { )} ) : null} - + - + ); } @@ -895,7 +901,8 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 20, paddingTop: 0, }, diff --git a/frontend/app/(tabs)/index.tsx b/frontend/app/(tabs)/index.tsx index 88501fa..4cff38e 100644 --- a/frontend/app/(tabs)/index.tsx +++ b/frontend/app/(tabs)/index.tsx @@ -35,6 +35,7 @@ import { SectionHeader } from "@/components/SectionHeader"; import { StatPill } from "@/components/StatPill"; import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { subscribeToPostEvents } from "@/services/postEvents"; import { applyProjectEvent, @@ -59,6 +60,7 @@ export default function HomeScreen() { const [hasError, setHasError] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const motion = useMotionConfig(); + const { scrollY, onScroll } = useTopBlurScroll(); const revealValues = useRef([ new Animated.Value(0), new Animated.Value(0), @@ -259,13 +261,16 @@ export default function HomeScreen() { - } contentContainerStyle={[ @@ -384,9 +389,9 @@ export default function HomeScreen() { )} - + - + ); @@ -404,7 +409,7 @@ const styles = StyleSheet.create({ }, scrollContainer: { gap: 20, - paddingHorizontal: 16, + paddingHorizontal: 0, paddingTop: 0, }, loadingState: { diff --git a/frontend/app/(tabs)/profile.tsx b/frontend/app/(tabs)/profile.tsx index 93468b3..7b759fd 100644 --- a/frontend/app/(tabs)/profile.tsx +++ b/frontend/app/(tabs)/profile.tsx @@ -27,6 +27,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useAuth } from "@/contexts/AuthContext"; import { clearApiCache, @@ -96,6 +97,7 @@ export default function ProfileScreen() { const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; const hasLoadedRef = useRef(false); + const { scrollY, onScroll } = useTopBlurScroll(); const filteredFollowerUsers = React.useMemo(() => { const trimmed = followersQuery.trim().toLowerCase(); @@ -503,13 +505,16 @@ export default function ProfileScreen() { - } contentContainerStyle={[ @@ -780,9 +785,9 @@ export default function ProfileScreen() { )} - + - + { if (motion.prefersReducedMotion) { @@ -150,13 +152,16 @@ export default function ArchiveBytesScreen() { return ( - } contentContainerStyle={[ @@ -209,9 +214,9 @@ export default function ArchiveBytesScreen() { )} - + - + ); } @@ -224,7 +229,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, diff --git a/frontend/app/bytes.tsx b/frontend/app/bytes.tsx index b0e6337..a7a2eea 100644 --- a/frontend/app/bytes.tsx +++ b/frontend/app/bytes.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Animated, + FlatList, Pressable, RefreshControl, - ScrollView, StyleSheet, View, } from "react-native"; @@ -26,6 +26,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { subscribeToPostEvents } from "@/services/postEvents"; import { useAuth } from "@/contexts/AuthContext"; @@ -38,8 +39,14 @@ export default function BytesScreen() { const [hasError, setHasError] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [activeFilter, setActiveFilter] = useState<"all" | "following">("all"); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [pageIndex, setPageIndex] = useState(0); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + const followingIdsRef = useRef([]); + const pageSize = 20; useEffect(() => { if (motion.prefersReducedMotion) { @@ -55,15 +62,26 @@ export default function BytesScreen() { }, [motion, reveal]); const loadBytes = useCallback( - async (showLoader = true) => { + async ({ + showLoader = true, + nextPage = 0, + append = false, + }: { + showLoader?: boolean; + nextPage?: number; + append?: boolean; + } = {}) => { try { if (showLoader) { setIsLoading(true); } + const start = nextPage * pageSize; const [postFeedRaw, followingIdsRaw] = await Promise.all([ - getPostsFeed("time", 0, 30), + getPostsFeed("time", start, pageSize), activeFilter === "following" && user?.username - ? getUsersFollowing(user.username) + ? followingIdsRef.current.length + ? Promise.resolve(followingIdsRef.current) + : getUsersFollowing(user.username) : Promise.resolve([]), ]); const postFeed = Array.isArray(postFeedRaw) ? postFeedRaw : []; @@ -71,6 +89,10 @@ export default function BytesScreen() { ? followingIdsRaw : []; + if (activeFilter === "following") { + followingIdsRef.current = followingIds; + } + const visiblePosts = activeFilter === "following" && followingIds.length ? postFeed.filter((post) => followingIds.includes(post.user)) @@ -86,10 +108,14 @@ export default function BytesScreen() { }), ); - setPosts(uiPosts); + setPosts((prev) => (append ? prev.concat(uiPosts) : uiPosts)); + setPageIndex(nextPage); + setHasMore(postFeed.length === pageSize); setHasError(false); } catch { - setPosts([]); + if (!append) { + setPosts([]); + } setHasError(true); } finally { if (showLoader) { @@ -101,8 +127,11 @@ export default function BytesScreen() { ); useEffect(() => { - loadBytes(); - }, [loadBytes]); + followingIdsRef.current = []; + setHasMore(true); + setPageIndex(0); + loadBytes({ nextPage: 0 }); + }, [activeFilter, loadBytes]); useEffect(() => { return subscribeToPostEvents((event) => { @@ -140,122 +169,165 @@ export default function BytesScreen() { useFocusEffect( useCallback(() => { clearApiCache(); - loadBytes(false); + loadBytes({ showLoader: false, nextPage: 0 }); }, [loadBytes]), ); const handleRefresh = useCallback(async () => { setIsRefreshing(true); clearApiCache(); - await loadBytes(false); + followingIdsRef.current = []; + setHasMore(true); + setPageIndex(0); + await loadBytes({ showLoader: false, nextPage: 0 }); setIsRefreshing(false); }, [loadBytes]); - useAutoRefresh(() => loadBytes(false), { focusRefresh: false }); + useAutoRefresh(() => loadBytes({ showLoader: false, nextPage: 0 }), { + focusRefresh: false, + }); + + const handleLoadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) { + return; + } + setIsLoadingMore(true); + loadBytes({ + showLoader: false, + nextPage: pageIndex + 1, + append: true, + }).finally(() => setIsLoadingMore(false)); + }, [hasMore, isLoading, isLoadingMore, loadBytes, pageIndex]); return ( - String(item.id)} + renderItem={({ item }) => } + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + onScroll={onScroll} + scrollEventThrottle={16} refreshControl={ } - contentContainerStyle={[ - styles.container, - { paddingTop: insets.top + 8, paddingBottom: 96 + insets.bottom }, - ]} - > - - - - - All bytes + ListHeaderComponent={ + + + + + + All bytes + + + Full feed of shipped updates. + + + + + {["all", "following"].map((key) => { + const isActive = key === activeFilter; + return ( + + setActiveFilter(key as "all" | "following") + } + style={[ + styles.filterChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {key === "all" ? "All" : "Following"} + + + ); + })} + + + {isLoading ? ( + + {[0, 1, 2].map((key) => ( + + ))} + + ) : null} + + } + ListEmptyComponent={ + !isLoading ? ( + + + {hasError + ? "Feed unavailable. Check the API and try again." + : activeFilter === "following" + ? "No bytes from people you follow yet." + : "No bytes yet."} + + ) : null + } + ListFooterComponent={ + isLoadingMore ? ( + - Full feed of shipped updates. + Loading more... - - - {["all", "following"].map((key) => { - const isActive = key === activeFilter; - return ( - setActiveFilter(key as "all" | "following")} - style={[ - styles.filterChip, - { - backgroundColor: isActive - ? colors.tint - : colors.surfaceAlt, - borderColor: colors.border, - }, - ]} - > - - {key === "all" ? "All" : "Following"} - - - ); - })} - - - - {isLoading ? ( - - {[0, 1, 2].map((key) => ( - - ))} - - ) : posts.length ? ( - posts.map((post) => ) - ) : ( - - - {hasError - ? "Feed unavailable. Check the API and try again." - : activeFilter === "following" - ? "No bytes from people you follow yet." - : "No bytes yet."} - - - )} - + ) : null + } + contentContainerStyle={[ + styles.container, + { paddingTop: insets.top + 8, paddingBottom: 96 + insets.bottom }, + ]} + removeClippedSubviews + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={8} + updateCellsBatchingPeriod={50} + /> - + ); } @@ -271,7 +343,8 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, @@ -305,6 +378,10 @@ const styles = StyleSheet.create({ borderWidth: 1, opacity: 0.7, }, + loadingMore: { + alignItems: "center", + paddingVertical: 18, + }, emptyState: { alignItems: "center", paddingVertical: 24, diff --git a/frontend/app/create-byte.tsx b/frontend/app/create-byte.tsx index 1058e9b..7072714 100644 --- a/frontend/app/create-byte.tsx +++ b/frontend/app/create-byte.tsx @@ -23,6 +23,7 @@ import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; import { useAuth } from "@/contexts/AuthContext"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { createPost, getProjectsByBuilderId, @@ -43,6 +44,7 @@ export default function CreateByteScreen() { const bottom = useBottomTabOverflow(); const reveal = React.useRef(new Animated.Value(0)).current; const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); const [projects, setProjects] = useState([]); const [projectId, setProjectId] = useState(null); const [content, setContent] = useState(""); @@ -182,10 +184,12 @@ export default function CreateByteScreen() { behavior={Platform.OS === "ios" ? "padding" : undefined} > - )} - + - + ); } diff --git a/frontend/app/create-stream.tsx b/frontend/app/create-stream.tsx index be1098b..438deeb 100644 --- a/frontend/app/create-stream.tsx +++ b/frontend/app/create-stream.tsx @@ -6,7 +6,6 @@ import { KeyboardAvoidingView, Platform, Pressable, - ScrollView, StyleSheet, TextInput, TouchableWithoutFeedback, @@ -22,6 +21,7 @@ import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; import { useAuth } from "@/contexts/AuthContext"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { createProject, uploadMedia } from "@/services/api"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; @@ -43,7 +43,8 @@ export default function CreateStreamScreen() { const motion = useMotionConfig(); const bottom = useBottomTabOverflow(); const reveal = React.useRef(new Animated.Value(0)).current; - const scrollRef = useRef(null); + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [aboutMd, setAboutMd] = useState(""); @@ -174,10 +175,12 @@ export default function CreateStreamScreen() { behavior={Platform.OS === "ios" ? "padding" : undefined} > - - + - + ); } diff --git a/frontend/app/manage-streams.tsx b/frontend/app/manage-streams.tsx index 43118f1..02413c0 100644 --- a/frontend/app/manage-streams.tsx +++ b/frontend/app/manage-streams.tsx @@ -6,6 +6,7 @@ import { RefreshControl, ScrollView, StyleSheet, + Text, TextInput, View, } from "react-native"; @@ -14,11 +15,13 @@ import { useSafeAreaInsets, } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; +import { useLocalSearchParams } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useAuth } from "@/contexts/AuthContext"; import { addProjectBuilder, @@ -34,6 +37,7 @@ import { ApiProject } from "@/constants/Types"; import { TagChip } from "@/components/TagChip"; import { MediaGallery } from "@/components/MediaGallery"; import { MarkdownText } from "@/components/MarkdownText"; +import Markdown from "react-native-markdown-display"; import { emitProjectDeleted, emitProjectUpdated, @@ -45,6 +49,8 @@ export default function ManageStreamsScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); const { user } = useAuth(); + const { editId } = useLocalSearchParams<{ editId?: string }>(); + const editTargetId = Number(editId); const getMediaLabel = (url: string) => { const trimmed = url.split("?")[0].split("#")[0]; return trimmed.split("/").pop() || "attachment"; @@ -84,6 +90,90 @@ export default function ManageStreamsScreen() { const [isRefreshing, setIsRefreshing] = useState(false); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + const toOneLine = (value: string) => value.replace(/\s+/g, " ").trim(); + const inlineMarkdownRules = { + paragraph: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + text: (node: { content?: string }) => node.content, + strong: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + em: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + s: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + code_inline: (node: { key?: string; content?: string }) => ( + + {` ${node.content} `} + + ), + } as const; + const inlineMarkdownStyle = { + body: { + color: colors.text, + fontSize: 15, + lineHeight: 20, + fontWeight: "600", + fontFamily: "SpaceMono", + }, + paragraph: { marginTop: 0, marginBottom: 0 }, + } as const; + const inlineSummaryStyle = { + ...inlineMarkdownStyle, + body: { + color: colors.muted, + fontSize: 13, + lineHeight: 18, + fontFamily: "SpaceMono", + }, + } as const; + const renderInlineMarkdown = (value: string, variant: "name" | "summary") => ( + ( + + {children} + + ), + }} + style={variant === "name" ? inlineMarkdownStyle : inlineSummaryStyle} + > + {toOneLine(value)} + + ); useEffect(() => { if (motion.prefersReducedMotion) { @@ -155,6 +245,22 @@ export default function ManageStreamsScreen() { loadStreams(); }, [loadStreams]); + useEffect(() => { + if (!editId || Number.isNaN(editTargetId)) { + return; + } + if (!projects.length) { + return; + } + const exists = projects.some((project) => project.id === editTargetId); + if (!exists) { + return; + } + setEditMap((prev) => + prev[editTargetId] ? prev : { ...prev, [editTargetId]: true }, + ); + }, [editId, editTargetId, projects]); + useFocusEffect( useCallback(() => { clearApiCache(); @@ -388,13 +494,16 @@ export default function ManageStreamsScreen() { return ( - } contentContainerStyle={[ @@ -464,9 +573,7 @@ export default function ManageStreamsScreen() { > - - {project.name} - + {renderInlineMarkdown(project.name, "name")} ) : ( - - {project.description} - + {renderInlineMarkdown(project.description, "summary")} {project.about_md ? ( {project.about_md} ) : null} @@ -831,9 +936,9 @@ export default function ManageStreamsScreen() { )} - + - + ); } @@ -846,7 +951,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, @@ -872,8 +978,31 @@ const styles = StyleSheet.create({ previewBlock: { gap: 8, }, - editBlock: { - gap: 10, + inlineCode: { + fontFamily: "SpaceMono", + borderRadius: 6, + paddingHorizontal: 4, + paddingVertical: 1, + }, + inlineNameText: { + fontSize: 15, + lineHeight: 20, + fontWeight: "600", + fontFamily: "SpaceMono", + }, + inlineSummaryText: { + fontSize: 13, + lineHeight: 18, + fontFamily: "SpaceMono", + }, + inlineStrong: { + fontWeight: "700", + }, + inlineEm: { + fontStyle: "italic", + }, + inlineStrike: { + textDecorationLine: "line-through", }, mediaSection: { gap: 10, diff --git a/frontend/app/notifications.tsx b/frontend/app/notifications.tsx index ef45830..0ed3307 100644 --- a/frontend/app/notifications.tsx +++ b/frontend/app/notifications.tsx @@ -16,6 +16,7 @@ import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useNotifications } from "@/contexts/NotificationsContext"; export default function NotificationsScreen() { @@ -24,6 +25,7 @@ export default function NotificationsScreen() { const router = useRouter(); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); const { notifications, isLoading, refresh, markRead, remove, clearAll } = useNotifications(); @@ -108,12 +110,15 @@ export default function NotificationsScreen() { return ( - } contentContainerStyle={{ paddingBottom: 96 + insets.bottom }} @@ -218,9 +223,9 @@ export default function NotificationsScreen() { )} - + - + ); } @@ -231,7 +236,7 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - paddingHorizontal: 16, + paddingHorizontal: 0, gap: 6, }, headerRow: { diff --git a/frontend/app/post/[postId].tsx b/frontend/app/post/[postId].tsx index b0469ed..a6d9e88 100644 --- a/frontend/app/post/[postId].tsx +++ b/frontend/app/post/[postId].tsx @@ -59,6 +59,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; @@ -76,6 +77,7 @@ export default function PostDetailScreen() { const { user } = useAuth(); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); const [post, setPost] = useState(null); const [project, setProject] = useState(null); const [author, setAuthor] = useState(null); @@ -645,13 +647,16 @@ export default function PostDetailScreen() { behavior={Platform.OS === "ios" ? "padding" : undefined} > - } keyboardShouldPersistTaps="handled" @@ -1239,11 +1244,11 @@ export default function PostDetailScreen() { )} - + - + ); } @@ -1256,7 +1261,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, diff --git a/frontend/app/saved-library.tsx b/frontend/app/saved-library.tsx index 6e9e30c..0ecc529 100644 --- a/frontend/app/saved-library.tsx +++ b/frontend/app/saved-library.tsx @@ -17,6 +17,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useSaved } from "@/contexts/SavedContext"; import { clearApiCache, @@ -36,6 +37,7 @@ export default function SavedLibraryScreen() { const [isRefreshing, setIsRefreshing] = useState(false); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); useEffect(() => { if (motion.prefersReducedMotion) { @@ -148,13 +150,16 @@ export default function SavedLibraryScreen() { return ( - } contentContainerStyle={[ @@ -207,9 +212,9 @@ export default function SavedLibraryScreen() { )} - + - + ); } @@ -222,7 +227,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, diff --git a/frontend/app/saved-streams.tsx b/frontend/app/saved-streams.tsx index 70567d0..aa859a9 100644 --- a/frontend/app/saved-streams.tsx +++ b/frontend/app/saved-streams.tsx @@ -17,6 +17,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useSavedStreams } from "@/contexts/SavedStreamsContext"; import { clearApiCache, @@ -40,6 +41,7 @@ export default function SavedStreamsScreen() { const [isRefreshing, setIsRefreshing] = useState(false); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); useEffect(() => { if (motion.prefersReducedMotion) { @@ -127,13 +129,16 @@ export default function SavedStreamsScreen() { return ( - } contentContainerStyle={[ @@ -200,9 +205,9 @@ export default function SavedStreamsScreen() { )} - + - + ); } @@ -215,7 +220,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, diff --git a/frontend/app/settings.tsx b/frontend/app/settings.tsx index 7d922cf..74e4a7e 100644 --- a/frontend/app/settings.tsx +++ b/frontend/app/settings.tsx @@ -3,12 +3,10 @@ import { ActivityIndicator, Alert, Animated, - Image, Keyboard, KeyboardAvoidingView, Platform, Pressable, - ScrollView, Switch, StyleSheet, TextInput, @@ -23,6 +21,7 @@ import { import { useRouter } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { TopBlur } from "@/components/TopBlur"; +import { FadeInImage } from "@/components/FadeInImage"; import { useAuth } from "@/contexts/AuthContext"; import { usePreferences } from "@/contexts/PreferencesContext"; import { @@ -33,6 +32,7 @@ import { } from "@/services/api"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import * as ImagePicker from "expo-image-picker"; export default function SettingsScreen() { @@ -43,7 +43,8 @@ export default function SettingsScreen() { const { preferences, updatePreferences } = usePreferences(); const motion = useMotionConfig(); const reveal = React.useRef(new Animated.Value(0)).current; - const scrollRef = useRef(null); + const scrollRef = useRef(null); + const { scrollY, onScroll } = useTopBlurScroll(); const [picture, setPicture] = useState(user?.picture ?? ""); const [pendingPicture, setPendingPicture] = useState<{ uri: string; @@ -284,10 +285,12 @@ export default function SettingsScreen() { > - {picture ? ( - @@ -857,12 +860,12 @@ export default function SettingsScreen() { - + - + ); } diff --git a/frontend/app/stream/[projectId].tsx b/frontend/app/stream/[projectId].tsx index c541492..e012ffc 100644 --- a/frontend/app/stream/[projectId].tsx +++ b/frontend/app/stream/[projectId].tsx @@ -12,8 +12,10 @@ import { RefreshControl, ScrollView, StyleSheet, + Text, View, } from "react-native"; +import { Feather } from "@expo/vector-icons"; import { SafeAreaView, useSafeAreaInsets, @@ -35,12 +37,14 @@ import { Post } from "@/components/Post"; import { TagChip } from "@/components/TagChip"; import { ThemedText } from "@/components/ThemedText"; import { MarkdownText } from "@/components/MarkdownText"; +import Markdown from "react-native-markdown-display"; import { MediaGallery } from "@/components/MediaGallery"; import { TopBlur } from "@/components/TopBlur"; import { useBottomTabOverflow } from "@/components/ui/TabBarBackground"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useAuth } from "@/contexts/AuthContext"; import { useSavedStreams } from "@/contexts/SavedStreamsContext"; import { emitProjectStats } from "@/services/projectEvents"; @@ -48,6 +52,8 @@ import { emitProjectStats } from "@/services/projectEvents"; const ensureUrlScheme = (url: string) => /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`; +const toOneLine = (value: string) => value.replace(/\s+/g, " ").trim(); + export default function StreamDetailScreen() { const colors = useAppColors(); const insets = useSafeAreaInsets(); @@ -69,9 +75,66 @@ export default function StreamDetailScreen() { const bottom = useBottomTabOverflow(); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); const prevSavedRef = useRef(false); const hasInitializedSaveRef = useRef(false); + const inlineMarkdownRules = { + paragraph: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + text: (node: { content?: string }) => node.content, + strong: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + em: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + s: (node: { key?: string }, children: React.ReactNode[]) => ( + + {children} + + ), + code_inline: (node: { key?: string; content?: string }) => ( + + {` ${node.content} `} + + ), + link: (node: { key?: string; attributes?: any }, children: any) => ( + + void Linking.openURL(ensureUrlScheme(node.attributes?.href ?? "")) + } + > + {children} + + ), + } as const; + + const inlineMarkdownStyle = { + body: { marginTop: 0, marginBottom: 0 }, + paragraph: { marginTop: 0, marginBottom: 0 }, + } as const; + const projectIdNumber = useMemo(() => Number(projectId), [projectId]); const isCreator = useMemo( () => (project && user?.id ? project.owner === user.id : false), @@ -233,13 +296,16 @@ export default function StreamDetailScreen() { return ( - } scrollIndicatorInsets={{ bottom: bottom + insets.bottom }} @@ -274,9 +340,12 @@ export default function StreamDetailScreen() { ) : project ? ( - - {project.name} - + + {toOneLine(project.name)} + {createdLabel || creatorName ? ( {createdLabel ? ( @@ -335,6 +404,38 @@ export default function StreamDetailScreen() { ) : null} + {isCreator || isBuilder ? ( + + router.push({ + pathname: "/manage-streams", + params: { editId: String(projectIdNumber) }, + }) + } + style={({ pressed }) => [ + styles.saveButton, + { + borderColor: colors.border, + backgroundColor: colors.surfaceAlt, + }, + pressed && styles.saveButtonPressed, + ]} + > + + + + Edit + + + + ) : null} [ @@ -347,19 +448,49 @@ export default function StreamDetailScreen() { ]} disabled={isSaving} > - - {saveCount} - + + + + {saveCount} + + - - {project.description} - + ( + + {children} + + ), + }} + style={inlineMarkdownStyle} + > + {toOneLine(project.description)} + {project.about_md ? ( {project.about_md} @@ -411,9 +542,9 @@ export default function StreamDetailScreen() { )} - + - + ); } @@ -426,7 +557,8 @@ const styles = StyleSheet.create({ flex: 1, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, @@ -468,6 +600,11 @@ const styles = StyleSheet.create({ paddingVertical: 6, borderColor: "transparent", }, + saveButtonContent: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, saveButtonPressed: { opacity: 0.8, transform: [{ scale: 0.98 }], @@ -476,6 +613,35 @@ const styles = StyleSheet.create({ fontSize: 24, lineHeight: 28, }, + inlineTitleText: { + fontSize: 24, + lineHeight: 28, + fontWeight: "700", + fontFamily: "SpaceMono", + }, + inlineSummaryText: { + fontSize: 12, + lineHeight: 18, + fontFamily: "SpaceMono", + }, + inlineStrong: { + fontWeight: "700", + }, + inlineEm: { + fontStyle: "italic", + }, + inlineStrike: { + textDecorationLine: "line-through", + }, + inlineCode: { + fontFamily: "SpaceMono", + borderRadius: 6, + paddingHorizontal: 4, + paddingVertical: 1, + }, + inlineLink: { + textDecorationLine: "underline", + }, linkList: { gap: 6, }, diff --git a/frontend/app/streams.tsx b/frontend/app/streams.tsx index 87f6c7d..27f1121 100644 --- a/frontend/app/streams.tsx +++ b/frontend/app/streams.tsx @@ -7,9 +7,9 @@ import React, { } from "react"; import { Animated, + FlatList, Pressable, RefreshControl, - ScrollView, StyleSheet, View, } from "react-native"; @@ -31,6 +31,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useAuth } from "@/contexts/AuthContext"; import { useSavedStreams } from "@/contexts/SavedStreamsContext"; import { @@ -51,8 +52,16 @@ export default function StreamsScreen() { const [hasError, setHasError] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [activeFilter, setActiveFilter] = useState<"all" | "saved">("all"); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [pageIndex, setPageIndex] = useState(0); + const [builderProjects, setBuilderProjects] = useState( + [] as ReturnType[], + ); const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; + const { scrollY, onScroll } = useTopBlurScroll(); + const pageSize = 24; useEffect(() => { if (motion.prefersReducedMotion) { @@ -68,41 +77,64 @@ export default function StreamsScreen() { }, [motion, reveal]); const loadStreams = useCallback( - async (showLoader = true) => { + async ({ + showLoader = true, + nextPage = 0, + append = false, + }: { + showLoader?: boolean; + nextPage?: number; + append?: boolean; + } = {}) => { try { if (showLoader) { setIsLoading(true); } + const start = nextPage * pageSize; const [projectFeedRaw, builderProjectsRaw] = await Promise.all([ - getProjectsFeed("time", 0, 30), - user?.id ? getProjectsByBuilderId(user.id) : Promise.resolve([]), + getProjectsFeed("time", start, pageSize), + nextPage === 0 && user?.id + ? getProjectsByBuilderId(user.id) + : Promise.resolve([]), ]); const projectFeed = Array.isArray(projectFeedRaw) ? projectFeedRaw : []; const builderProjects = Array.isArray(builderProjectsRaw) ? builderProjectsRaw : []; - const combinedMap = new Map( - projectFeed.map((project) => [project.id, project]), - ); - builderProjects.forEach((project) => { - combinedMap.set(project.id, project); - }); - const combinedProjects = Array.from(combinedMap.values()); const builderIds = builderProjects.map((project) => project.id); const builderCounts = await Promise.all( - combinedProjects.map((project) => + projectFeed.map((project) => getProjectBuilders(project.id).catch(() => []), ), ); - const uiProjects = combinedProjects.map((project, index) => + const uiProjects = projectFeed.map((project, index) => mapProjectToUi(project, builderCounts[index]?.length ?? 0), ); + const builderUi = builderProjects.length + ? await Promise.all( + builderProjects.map(async (project) => { + const builders = await getProjectBuilders(project.id).catch( + () => [], + ); + return mapProjectToUi(project, builders.length ?? 0); + }), + ) + : []; - setProjects(uiProjects); - setBuilderProjectIds(builderIds); + setProjects((prev) => (append ? prev.concat(uiProjects) : uiProjects)); + if (nextPage === 0) { + setBuilderProjects(builderUi); + } + if (nextPage === 0) { + setBuilderProjectIds(builderIds); + } + setPageIndex(nextPage); + setHasMore(projectFeed.length === pageSize); setHasError(false); } catch { - setProjects([]); + if (!append) { + setProjects([]); + } setHasError(true); } finally { if (showLoader) { @@ -112,15 +144,26 @@ export default function StreamsScreen() { }, [user?.id], ); + const combinedProjects = useMemo(() => { + const combinedMap = new Map>(); + projects.forEach((project) => combinedMap.set(project.id, project)); + builderProjects.forEach((project) => combinedMap.set(project.id, project)); + return Array.from(combinedMap.values()); + }, [builderProjects, projects]); + const visibleProjects = useMemo(() => { if (activeFilter !== "saved") { - return projects; + return combinedProjects; } - return projects.filter((project) => savedProjectIds.includes(project.id)); - }, [activeFilter, projects, savedProjectIds]); + return combinedProjects.filter((project) => + savedProjectIds.includes(project.id), + ); + }, [activeFilter, combinedProjects, savedProjectIds]); useEffect(() => { - loadStreams(); + setHasMore(true); + setPageIndex(0); + loadStreams({ nextPage: 0 }); }, [loadStreams]); useEffect(() => { @@ -132,133 +175,170 @@ export default function StreamsScreen() { useFocusEffect( useCallback(() => { clearApiCache(); - loadStreams(false); + loadStreams({ showLoader: false, nextPage: 0 }); }, [loadStreams]), ); const handleRefresh = useCallback(async () => { setIsRefreshing(true); clearApiCache(); - await loadStreams(false); + setHasMore(true); + setPageIndex(0); + await loadStreams({ showLoader: false, nextPage: 0 }); setIsRefreshing(false); }, [loadStreams]); - useAutoRefresh(() => loadStreams(false), { focusRefresh: false }); + useAutoRefresh(() => loadStreams({ showLoader: false, nextPage: 0 }), { + focusRefresh: false, + }); + + const handleLoadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) { + return; + } + setIsLoadingMore(true); + loadStreams({ + showLoader: false, + nextPage: pageIndex + 1, + append: true, + }).finally(() => setIsLoadingMore(false)); + }, [hasMore, isLoading, isLoadingMore, loadStreams, pageIndex]); return ( - String(item.id)} + renderItem={({ item }) => ( + undefined} + /> + )} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.5} + onScroll={onScroll} + scrollEventThrottle={16} refreshControl={ } - contentContainerStyle={[ - styles.container, - { paddingTop: insets.top + 8, paddingBottom: 96 + insets.bottom }, - ]} - > - - - - - Active streams + ListHeaderComponent={ + + + + + + Active streams + + + Projects shipping right now. + + + + + {["all", "saved"].map((key) => { + const isActive = key === activeFilter; + return ( + setActiveFilter(key as "all" | "saved")} + style={[ + styles.filterChip, + { + backgroundColor: isActive + ? colors.tint + : colors.surfaceAlt, + borderColor: colors.border, + }, + ]} + > + + {key === "all" ? "All" : "Saved"} + + + ); + })} + + + {isLoading ? ( + + {[0, 1, 2].map((key) => ( + + ))} + + ) : null} + + } + ListEmptyComponent={ + !isLoading ? ( + + + {hasError + ? "Streams unavailable. Check the API and try again." + : activeFilter === "saved" + ? "No saved streams yet." + : "No streams yet."} + + ) : null + } + ListFooterComponent={ + isLoadingMore ? ( + - Projects shipping right now. + Loading more... - - - {["all", "saved"].map((key) => { - const isActive = key === activeFilter; - return ( - setActiveFilter(key as "all" | "saved")} - style={[ - styles.filterChip, - { - backgroundColor: isActive - ? colors.tint - : colors.surfaceAlt, - borderColor: colors.border, - }, - ]} - > - - {key === "all" ? "All" : "Saved"} - - - ); - })} - - - - {isLoading ? ( - - {[0, 1, 2].map((key) => ( - - ))} - - ) : visibleProjects.length ? ( - - {visibleProjects.map((project) => ( - undefined} - /> - ))} - - ) : ( - - - {hasError - ? "Streams unavailable. Check the API and try again." - : activeFilter === "saved" - ? "No saved streams yet." - : "No streams yet."} - - - )} - + ) : null + } + contentContainerStyle={[ + styles.container, + { paddingTop: insets.top + 8, paddingBottom: 96 + insets.bottom }, + ]} + removeClippedSubviews + initialNumToRender={6} + maxToRenderPerBatch={6} + windowSize={8} + updateCellsBatchingPeriod={50} + /> - + ); } @@ -274,7 +354,8 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, @@ -311,6 +392,10 @@ const styles = StyleSheet.create({ borderWidth: 1, opacity: 0.7, }, + loadingMore: { + alignItems: "center", + paddingVertical: 18, + }, emptyState: { alignItems: "center", paddingVertical: 24, diff --git a/frontend/app/user/[username].tsx b/frontend/app/user/[username].tsx index 9ffed2d..1ee2d5e 100644 --- a/frontend/app/user/[username].tsx +++ b/frontend/app/user/[username].tsx @@ -7,6 +7,7 @@ import React, { } from "react"; import { Animated, + FlatList, Modal, Pressable, RefreshControl, @@ -33,6 +34,7 @@ import { TopBlur } from "@/components/TopBlur"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { useAppColors } from "@/hooks/useAppColors"; import { useMotionConfig } from "@/hooks/useMotionConfig"; +import { useTopBlurScroll } from "@/hooks/useTopBlurScroll"; import { useAuth } from "@/contexts/AuthContext"; import { clearApiCache, @@ -86,6 +88,7 @@ export default function UserProfileScreen() { const motion = useMotionConfig(); const reveal = useRef(new Animated.Value(0)).current; const hasLoadedRef = useRef(false); + const { scrollY, onScroll } = useTopBlurScroll(); const filteredFollowerUsers = useMemo(() => { const trimmed = followersQuery.trim().toLowerCase(); @@ -379,13 +382,16 @@ export default function UserProfileScreen() { - } contentContainerStyle={[ @@ -538,9 +544,9 @@ export default function UserProfileScreen() { )} - + - + - - {isFollowersLoading ? ( - - Loading... - - ) : filteredFollowerUsers.length ? ( - filteredFollowerUsers.map((item) => ( - { - setIsFollowersOpen(false); - router.push({ - pathname: "/user/[username]", - params: { username: item.username }, - }); - }} - onToggleFollow={() => handleToggleModalFollow(item)} - /> - )) - ) : followersQuery.trim() ? ( - - No matches found. - - ) : ( - - No followers yet. - + item.username} + renderItem={({ item }) => ( + { + setIsFollowersOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleModalFollow(item)} + /> )} - + ItemSeparatorComponent={() => } + ListEmptyComponent={ + isFollowersLoading ? ( + + Loading... + + ) : followersQuery.trim() ? ( + + No matches found. + + ) : ( + + No followers yet. + + ) + } + contentContainerStyle={styles.modalList} + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + updateCellsBatchingPeriod={50} + /> @@ -628,38 +643,47 @@ export default function UserProfileScreen() { }, ]} /> - - {isFollowingLoading ? ( - - Loading... - - ) : filteredFollowingUsers.length ? ( - filteredFollowingUsers.map((item) => ( - { - setIsFollowingOpen(false); - router.push({ - pathname: "/user/[username]", - params: { username: item.username }, - }); - }} - onToggleFollow={() => handleToggleModalFollow(item)} - /> - )) - ) : followingQuery.trim() ? ( - - No matches found. - - ) : ( - - Not following anyone yet. - + item.username} + renderItem={({ item }) => ( + { + setIsFollowingOpen(false); + router.push({ + pathname: "/user/[username]", + params: { username: item.username }, + }); + }} + onToggleFollow={() => handleToggleModalFollow(item)} + /> )} - + ItemSeparatorComponent={() => } + ListEmptyComponent={ + isFollowingLoading ? ( + + Loading... + + ) : followingQuery.trim() ? ( + + No matches found. + + ) : ( + + Not following anyone yet. + + ) + } + contentContainerStyle={styles.modalList} + removeClippedSubviews + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + updateCellsBatchingPeriod={50} + /> @@ -678,7 +702,8 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, }, container: { - padding: 16, + paddingVertical: 16, + paddingHorizontal: 0, gap: 16, paddingTop: 0, }, @@ -716,6 +741,9 @@ const styles = StyleSheet.create({ padding: 16, gap: 12, }, + modalList: { + paddingBottom: 8, + }, modalRow: { paddingVertical: 8, }, diff --git a/frontend/components/Collapsible.tsx b/frontend/components/Collapsible.tsx index 8761861..dac87b3 100644 --- a/frontend/components/Collapsible.tsx +++ b/frontend/components/Collapsible.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, useState } from "react"; +import { PropsWithChildren, type ReactNode, useState } from "react"; import { StyleSheet, TouchableOpacity } from "react-native"; import { ThemedText } from "@/components/ThemedText"; @@ -6,17 +6,29 @@ import { ThemedView } from "@/components/ThemedView"; import { IconSymbol } from "@/components/ui/IconSymbol"; import { useAppColors } from "@/hooks/useAppColors"; +type CollapsibleProps = PropsWithChildren & { + title: ReactNode; + defaultOpen?: boolean; +}; + export function Collapsible({ children, title, -}: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); + defaultOpen = false, +}: CollapsibleProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); const colors = useAppColors(); + const isTitleText = typeof title === "string" || typeof title === "number"; return ( - + setIsOpen((value) => !value)} activeOpacity={0.8} > @@ -28,21 +40,38 @@ export function Collapsible({ style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }} /> - {title} + {isTitleText ? ( + {title} + ) : ( + title + )} - {isOpen && {children}} + {isOpen && ( + + {children} + + )} ); } const styles = StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + overflow: "hidden", + }, heading: { flexDirection: "row", alignItems: "center", gap: 6, + paddingHorizontal: 12, + paddingVertical: 10, }, content: { - marginTop: 6, - marginLeft: 24, + paddingHorizontal: 12, + paddingVertical: 10, }, }); diff --git a/frontend/components/FadeInImage.tsx b/frontend/components/FadeInImage.tsx new file mode 100644 index 0000000..9712541 --- /dev/null +++ b/frontend/components/FadeInImage.tsx @@ -0,0 +1,36 @@ +import React, { useRef, useState } from "react"; +import { Animated, ImageProps } from "react-native"; + +type FadeInImageProps = ImageProps & { + duration?: number; +}; + +export function FadeInImage({ + duration = 150, + onLoad, + style, + ...props +}: FadeInImageProps) { + const opacity = useRef(new Animated.Value(0)).current; + const [hasLoaded, setHasLoaded] = useState(false); + + const handleLoad: ImageProps["onLoad"] = (event) => { + if (!hasLoaded) { + setHasLoaded(true); + Animated.timing(opacity, { + toValue: 1, + duration, + useNativeDriver: true, + }).start(); + } + onLoad?.(event); + }; + + return ( + + ); +} diff --git a/frontend/components/LazyFadeIn.tsx b/frontend/components/LazyFadeIn.tsx new file mode 100644 index 0000000..68f2ed4 --- /dev/null +++ b/frontend/components/LazyFadeIn.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useRef } from "react"; +import { Animated, StyleProp, ViewStyle } from "react-native"; + +type LazyFadeInProps = { + visible: boolean; + duration?: number; + style?: StyleProp; + children?: React.ReactNode; +}; + +export function LazyFadeIn({ + visible, + duration = 150, + style, + children, +}: LazyFadeInProps) { + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!visible) { + return; + } + Animated.timing(opacity, { + toValue: 1, + duration, + useNativeDriver: true, + }).start(); + }, [duration, opacity, visible]); + + if (!visible) { + return ; + } + + return {children}; +} diff --git a/frontend/components/MarkdownText.tsx b/frontend/components/MarkdownText.tsx index 816a443..f58d138 100644 --- a/frontend/components/MarkdownText.tsx +++ b/frontend/components/MarkdownText.tsx @@ -1,9 +1,20 @@ -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Markdown from "react-native-markdown-display"; import * as Linking from "expo-linking"; -import * as Clipboard from "expo-clipboard"; -import { Alert, Pressable, StyleSheet, Text, View } from "react-native"; +import { + Alert, + Image, + Pressable, + StyleSheet, + Text, + useWindowDimensions, + View, +} from "react-native"; +import { Collapsible } from "@/components/Collapsible"; +import { FadeInImage } from "@/components/FadeInImage"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; import { useAppColors } from "@/hooks/useAppColors"; +import { useDeferredRender } from "@/hooks/useDeferredRender"; import { usePreferences } from "@/contexts/PreferencesContext"; type MarkdownTextProps = { @@ -13,13 +24,150 @@ type MarkdownTextProps = { export function MarkdownText({ children }: MarkdownTextProps) { const colors = useAppColors(); const { preferences } = usePreferences(); - const [copiedText, setCopiedText] = useState(null); + const { width: windowWidth } = useWindowDimensions(); + const isReady = useDeferredRender(); const linkifyText = (text: string) => text.replace( /(^|\s)((?:https?:\/\/|www\.)[^\s<]+[^\s<\.)])/g, (_match, prefix, url) => `${prefix}[${url}](${url})`, ); - const content = linkifyText(children); + const sanitizeGithubCallouts = (text: string) => + text.replace( + /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*>\s*/gim, + "> [!$1] ", + ); + const applyTaskCheckboxes = (text: string) => + text.replace( + /(^|\n)(\s*)[-*]\s+\[( |x|X)\]\s+/g, + (_match, lineStart, indent, mark) => + `${lineStart}${indent.replace(/\s/g, "\u00A0")}${ + mark.trim().toLowerCase() === "x" ? "☑" : "☐" + }\u00A0`, + ); + const normalizeListTransitions = (text: string) => { + const lines = text.split(/\r?\n/); + const result: string[] = []; + let prevType: "ordered" | "unordered" | null = null; + let prevIndent = ""; + + const getListInfo = (line: string) => { + const match = line.match(/^((?:>\s*)*)(\s*)(\d+\.|[-*+])\s+/); + if (!match) return null; + const marker = match[3]; + return { + indent: `${match[1]}${match[2]}`, + type: /\d+\./.test(marker) ? "ordered" : "unordered", + } as const; + }; + + lines.forEach((line) => { + const info = getListInfo(line); + if ( + info && + prevType && + info.indent === prevIndent && + info.type !== prevType && + result.length > 0 && + result[result.length - 1].trim() !== "" + ) { + result.push(""); + } + result.push(line); + if (info) { + prevType = info.type; + prevIndent = info.indent; + } else if (line.trim() === "") { + prevType = null; + prevIndent = ""; + } + }); + + return result.join("\n"); + }; + const preprocessMarkdown = (text: string) => + linkifyText( + normalizeListTransitions( + applyTaskCheckboxes(sanitizeGithubCallouts(text)), + ), + ); + + const normalizeDetailsTags = (text: string) => + text + .replace(/&lt;(\/?details[^&]*)&gt;/gi, "<$1>") + .replace(/&lt;(\/?summary[^&]*)&gt;/gi, "<$1>") + .replace(/<(\/?details[^&]*)>/gi, "<$1>") + .replace(/<(\/?summary[^&]*)>/gi, "<$1>") + .replace(/\\<(\/?details[^>]*)>/gi, "<$1>") + .replace(/\\<(\/?summary[^>]*)>/gi, "<$1>"); + + const parseDetailsBlocks = (text: string) => { + const blocks: Array< + | { type: "markdown"; content: string } + | { type: "details"; summary: string; content: string; open: boolean } + > = []; + const tagRegex = /<\/?details[^>]*>/gi; + let cursor = 0; + let depth = 0; + let openContentStart = -1; + let openTagIndex = -1; + let openByDefault = false; + let match: RegExpExecArray | null = tagRegex.exec(text); + + while (match) { + const tag = match[0]; + const isClose = tag.startsWith(" cursor) { + blocks.push({ + type: "markdown", + content: text.slice(cursor, match.index), + }); + } + openTagIndex = match.index; + openContentStart = match.index + tag.length; + openByDefault = /]*\bopen\b/i.test(tag); + } + depth += 1; + } else { + if (depth > 0) { + depth -= 1; + if (depth === 0 && openContentStart !== -1) { + const inner = text.slice(openContentStart, match.index); + const summaryMatch = inner.match( + /]*>([\s\S]*?)<\/summary>/i, + ); + const summaryRaw = summaryMatch?.[1]?.trim() ?? "Details"; + const content = summaryMatch + ? inner.replace(summaryMatch[0], "").trim() + : inner.trim(); + blocks.push({ + type: "details", + summary: summaryRaw, + content, + open: openByDefault, + }); + cursor = match.index + tag.length; + openContentStart = -1; + openTagIndex = -1; + openByDefault = false; + } + } + } + match = tagRegex.exec(text); + } + + if (depth > 0 && openTagIndex !== -1) { + blocks.push({ type: "markdown", content: text.slice(openTagIndex) }); + return blocks; + } + + if (cursor < text.length) { + blocks.push({ type: "markdown", content: text.slice(cursor) }); + } + + return blocks; + }; const openUrlSafe = async (url: string) => { const trimmed = url.trim(); if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { @@ -51,14 +199,6 @@ export function MarkdownText({ children }: MarkdownTextProps) { await Linking.openURL(trimmed); }; - const handleCopy = async (text: string) => { - await Clipboard.setStringAsync(text); - setCopiedText(text); - setTimeout(() => { - setCopiedText((prev) => (prev === text ? null : prev)); - }, 1400); - }; - const getCodeContent = (node: { content?: string; children?: any[] }) => { if (typeof node.content === "string") { return node.content; @@ -75,38 +215,21 @@ export function MarkdownText({ children }: MarkdownTextProps) { children?: any[]; }) => { const content = getCodeContent(node); - const copied = copiedText === content; return ( - - Code - void handleCopy(content)} - style={({ pressed }) => [ - styles.copyButton, - { borderColor: colors.border, backgroundColor: colors.surface }, - pressed && styles.copyButtonPressed, - ]} - > - - {copied ? "Copied" : "Copy"} - - - @@ -116,94 +239,432 @@ export function MarkdownText({ children }: MarkdownTextProps) { ); }; - return ( - { - void openUrlSafe(url); - return false; - }} - rules={{ - code_inline: (node) => ( - { + const src = node.attributes?.src ?? ""; + if (!src) { + return null; + } + const maxWidth = Math.min(windowWidth - 32, 520); + return ; + }; + + const renderLink = ( + node: { key?: string; attributes?: any; children?: any[] }, + children: React.ReactNode[] = [], + ) => { + const href = node.attributes?.href ?? ""; + const hasImageChild = Array.isArray(node.children) + ? node.children.some((child) => child?.type === "image") + : false; + + if (hasImageChild) { + return ( + void openUrlSafe(href)} + style={styles.imageLink} + > + {children} + + ); + } + + return ( + void openUrlSafe(href)} + > + {children} + + ); + }; + + const extractText = (value: React.ReactNode): string => { + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + if (Array.isArray(value)) { + return value.map(extractText).join(""); + } + if (React.isValidElement(value)) { + return extractText((value.props as any)?.children); + } + return ""; + }; + + const stripPrefix = ( + nodes: React.ReactNode, + prefix: string, + ): React.ReactNode[] => { + let remaining = prefix; + const stripNode = (node: React.ReactNode): React.ReactNode | null => { + if (!remaining) return node; + if (typeof node === "string" || typeof node === "number") { + const text = String(node); + if (text.startsWith(remaining)) { + const updated = text.slice(remaining.length); + remaining = ""; + return updated; + } + if (remaining.startsWith(text)) { + remaining = remaining.slice(text.length); + return null; + } + return node; + } + if (React.isValidElement(node)) { + const childNodes = React.Children.toArray( + (node.props as any)?.children, + ); + const updatedChildren = childNodes + .map(stripNode) + .filter((child) => child !== null); + return React.cloneElement(node, node.props as any, updatedChildren); + } + return node; + }; + + return React.Children.toArray(nodes) + .map(stripNode) + .filter((child) => child !== null); + }; + + const renderBlockquote = ( + node: { key?: string }, + children: React.ReactNode, + ) => { + const text = extractText(children); + const match = text.match( + /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, + ); + const type = match?.[1]?.toLowerCase() as + | "note" + | "tip" + | "important" + | "warning" + | "caution" + | undefined; + if (!type) { + return ( + + {children} + + ); + } + + const palette = calloutPalette[type]; + const cleanedChildren = stripPrefix(children, match?.[0] ?? ""); + + return ( + + + {type.charAt(0).toUpperCase() + type.slice(1)} + + {cleanedChildren} + + ); + }; + + const markdownRules = { + code_inline: (node: { key?: string; content?: string }) => ( + + {`\u00A0${node.content}\u00A0`} + + ), + code_block: renderCodeBlock, + fence: renderCodeBlock, + image: renderImage, + blockquote: renderBlockquote, + link: renderLink, + }; + + const markdownStyle = { + body: { color: colors.text, fontSize: 14, lineHeight: 20 }, + heading1: { color: colors.text, fontSize: 20, marginBottom: 6 }, + heading2: { color: colors.text, fontSize: 18, marginBottom: 6 }, + heading3: { color: colors.text, fontSize: 16, marginBottom: 6 }, + hr: { + borderBottomColor: colors.border, + borderBottomWidth: 1, + marginVertical: 16, + }, + bullet_list: { paddingLeft: 12 }, + ordered_list: { paddingLeft: 12 }, + list_item: { marginBottom: 4 }, + task_list_item: { marginBottom: 4 }, + checkbox: { + borderColor: colors.border, + backgroundColor: colors.surface, + }, + table: { + borderColor: colors.border, + borderWidth: 1, + borderRadius: 8, + overflow: "hidden", + }, + th: { + backgroundColor: colors.surfaceAlt, + borderColor: colors.border, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 6, + }, + td: { + borderColor: colors.border, + borderWidth: 1, + paddingHorizontal: 8, + paddingVertical: 6, + }, + image: { + borderRadius: 10, + marginVertical: 8, + }, + link: { color: colors.tint }, + em: { fontStyle: "italic" }, + strong: { fontWeight: "700" }, + s: { textDecorationLine: "line-through" }, + } as const; + + const summaryMarkdownStyle = { + body: { color: colors.text, fontSize: 15, lineHeight: 20 }, + paragraph: { marginTop: 0, marginBottom: 0 }, + link: { color: colors.tint }, + em: { fontStyle: "italic" }, + strong: { fontWeight: "700" }, + s: { textDecorationLine: "line-through" }, + } as const; + + const renderMarkdownSegments = (text: string, keyPrefix: string) => { + const normalized = normalizeDetailsTags(text); + return parseDetailsBlocks(normalized).map((block, index) => { + const keyBase = `${keyPrefix}-${index}`; + if (block.type === "details") { + const summaryText = block.summary.replace(/\s+/g, " ").trim(); + return ( + { + void openUrlSafe(url); + return false; + }} + rules={markdownRules} + style={summaryMarkdownStyle} + > + {preprocessMarkdown(summaryText)} + + } + defaultOpen={block.open} > - {node.content} - - ), - code_block: renderCodeBlock, - fence: renderCodeBlock, - }} - style={{ - body: { color: colors.text, fontSize: 14, lineHeight: 20 }, - heading1: { color: colors.text, fontSize: 20, marginBottom: 6 }, - heading2: { color: colors.text, fontSize: 18, marginBottom: 6 }, - heading3: { color: colors.text, fontSize: 16, marginBottom: 6 }, - blockquote: { - borderLeftColor: colors.border, - borderLeftWidth: 3, - paddingLeft: 10, - color: colors.muted, - }, - link: { color: colors.tint }, - }} - > - {content} - + + {renderMarkdownSegments(block.content, `nested-${keyBase}`)} + + + ); + } + return ( + { + void openUrlSafe(url); + return false; + }} + rules={markdownRules} + style={markdownStyle} + > + {preprocessMarkdown(block.content)} + + ); + }); + }; + + const markdownNodes = useMemo(() => { + if (!isReady) { + return null; + } + return renderMarkdownSegments(children, "root"); + }, [children, colors, isReady, preferences.linkOpenMode, windowWidth]); + + return ( + + {markdownNodes} + + ); +} + +type MarkdownImageProps = { + src: string; + maxWidth: number; +}; + +const calloutPalette = { + note: { + border: "#3B82F6", + background: "rgba(59, 130, 246, 0.12)", + title: "#3B82F6", + }, + tip: { + border: "#22C55E", + background: "rgba(34, 197, 94, 0.12)", + title: "#22C55E", + }, + important: { + border: "#EF4444", + background: "rgba(239, 68, 68, 0.12)", + title: "#EF4444", + }, + warning: { + border: "#F59E0B", + background: "rgba(245, 158, 11, 0.12)", + title: "#F59E0B", + }, + caution: { + border: "#FF3B30", + background: "rgba(255, 59, 48, 0.16)", + title: "#FF3B30", + glow: true, + }, +} as const; + +function MarkdownImage({ src, maxWidth }: MarkdownImageProps) { + const [aspectRatio, setAspectRatio] = useState(16 / 9); + const isReady = useDeferredRender(); + + useEffect(() => { + let active = true; + Image.getSize( + src, + (width, height) => { + if (!active || width <= 0 || height <= 0) return; + setAspectRatio(width / height); + }, + () => { + if (active) { + setAspectRatio(16 / 9); + } + }, + ); + return () => { + active = false; + }; + }, [src]); + + return ( + + {isReady ? ( + + ) : null} + ); } const styles = StyleSheet.create({ + markdownStack: { + gap: 10, + }, codeBlock: { borderRadius: 12, borderWidth: 1, - padding: 10, + padding: 0, + marginVertical: 10, gap: 8, }, - codeHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + blockquoteBase: { + borderLeftWidth: 1, + borderWidth: 1, + paddingLeft: 12, + paddingRight: 12, + paddingVertical: 8, + marginVertical: 8, + borderRadius: 10, + }, + calloutBase: { + gap: 4, + }, + calloutGlow: { + shadowOpacity: 0.35, + shadowRadius: 8, + shadowOffset: { width: 0, height: 3 }, + elevation: 4, }, - codeLabel: { + calloutTitle: { fontSize: 12, letterSpacing: 0.4, textTransform: "uppercase", + fontFamily: "SpaceMono", + }, + calloutContent: { + marginTop: 2, }, codeText: { fontFamily: "SpaceMono", fontSize: 13, lineHeight: 18, borderRadius: 10, - borderWidth: 1, - padding: 10, + borderWidth: 0, + paddingTop: 20, + paddingHorizontal: 20, + paddingBottom: 0, + paddingVertical: 0, }, inlineCode: { fontFamily: "SpaceMono", fontSize: 13, - borderRadius: 6, - borderWidth: 1, - paddingHorizontal: 6, - paddingVertical: 2, + lineHeight: 16, + borderRadius: 2, + paddingHorizontal: 0.5, + paddingVertical: 0.5, + overflow: "hidden", + includeFontPadding: false, }, - copyButton: { - borderRadius: 8, - borderWidth: 1, - paddingHorizontal: 10, - paddingVertical: 4, + imageLink: { + alignSelf: "flex-start", }, - copyButtonPressed: { - opacity: 0.8, - transform: [{ scale: 0.98 }], + link: { + textDecorationLine: "underline", }, - copyLabel: { - fontSize: 12, + image: { + borderRadius: 10, }, }); diff --git a/frontend/components/MediaGallery.tsx b/frontend/components/MediaGallery.tsx index 2905be4..9a80c0c 100644 --- a/frontend/components/MediaGallery.tsx +++ b/frontend/components/MediaGallery.tsx @@ -1,10 +1,13 @@ import React from "react"; -import { Image, Modal, Pressable, StyleSheet, View } from "react-native"; +import { Modal, Pressable, StyleSheet, View } from "react-native"; import { VideoView, useVideoPlayer } from "expo-video"; import { WebView } from "react-native-webview"; import * as Linking from "expo-linking"; +import { FadeInImage } from "@/components/FadeInImage"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; import { ThemedText } from "@/components/ThemedText"; import { useAppColors } from "@/hooks/useAppColors"; +import { useDeferredRender } from "@/hooks/useDeferredRender"; type MediaGalleryProps = { media?: string[]; @@ -50,6 +53,7 @@ import { resolveMediaUrl } from "@/services/api"; export function MediaGallery({ media }: MediaGalleryProps) { const colors = useAppColors(); + const isReady = useDeferredRender(); const [expandedImage, setExpandedImage] = React.useState(null); if (!media?.length) { return null; @@ -63,26 +67,37 @@ export function MediaGallery({ media }: MediaGalleryProps) { {normalizedMedia.map((item) => { if (isVideo(item)) { - return ; + return ( + + {isReady ? : null} + + ); } if (isSvg(item)) { const html = ``; return ( - + + {isReady ? ( + + ) : null} + ); } if (isImage(item)) { return ( setExpandedImage(item)}> - + + {isReady ? ( + + ) : null} + ); } @@ -110,7 +125,7 @@ export function MediaGallery({ media }: MediaGalleryProps) { onPress={() => setExpandedImage(null)} > {expandedImage ? ( - (null); + const saveMutationRef = useRef<{ value: boolean; ts: number } | null>(null); + const [isSavedLocal, setIsSavedLocal] = useState(isSaved(id)); + const previewContent = useMemo(() => { + if (localContent.length <= previewCharLimit) { + return localContent; + } + return localContent.slice(0, previewCharLimit).trimEnd(); + }, [localContent, previewCharLimit]); + const isTruncated = localContent.length > previewCharLimit; const initials = useMemo(() => { return username @@ -149,6 +162,10 @@ export function Post({ } }, [content, isEditing, media]); + useEffect(() => { + setIsSavedLocal(isSaved(id)); + }, [id, isSaved, savedPostIds]); + useEffect(() => { setLikeCount(likes); }, [likes]); @@ -178,6 +195,14 @@ export function Post({ setCommentCount(event.comments); } if (typeof event.isLiked === "boolean") { + const pending = likeMutationRef.current; + if (pending && Date.now() - pending.ts < 1500) { + if (event.isLiked === pending.value) { + likeMutationRef.current = null; + } else { + return; + } + } setIsLiked(event.isLiked); } }); @@ -196,7 +221,10 @@ export function Post({ ]); const safeComments = Array.isArray(commentList) ? commentList : []; if (isMounted) { - setIsLiked(likeStatus.status); + const pending = likeMutationRef.current; + if (!pending || Date.now() - pending.ts >= 1500) { + setIsLiked(likeStatus.status); + } setCommentCount(safeComments.length); } } catch { @@ -218,21 +246,27 @@ export function Post({ } setIsLikeUpdating(true); + const prevLiked = isLiked; + const prevLikes = likeCount; try { - if (isLiked) { - await unlikePost(user.username, id); - const nextLikes = Math.max(0, likeCount - 1); - setIsLiked(false); - setLikeCount(nextLikes); - emitPostStats(id, { likes: nextLikes, isLiked: false }); - } else { + const nextLiked = !prevLiked; + const nextLikes = Math.max(0, prevLikes + (nextLiked ? 1 : -1)); + setIsLiked(nextLiked); + setLikeCount(nextLikes); + emitPostStats(id, { likes: nextLikes, isLiked: nextLiked }); + likeMutationRef.current = { value: nextLiked, ts: Date.now() }; + + if (nextLiked) { await likePost(user.username, id); - const nextLikes = likeCount + 1; - setIsLiked(true); - setLikeCount(nextLikes); - emitPostStats(id, { likes: nextLikes, isLiked: true }); + } else { + await unlikePost(user.username, id); } + } catch { + setIsLiked(prevLiked); + setLikeCount(prevLikes); + emitPostStats(id, { likes: prevLikes, isLiked: prevLiked }); } finally { + likeMutationRef.current = null; setIsLikeUpdating(false); } }; @@ -241,12 +275,21 @@ export function Post({ if (isSaving) { return; } - const wasSaved = isSaved(id); + const wasSaved = isSavedLocal; + const prevCount = saveCount; + const nextSaved = !wasSaved; + const nextCount = Math.max(0, saveCount + (nextSaved ? 1 : -1)); setIsSaving(true); try { + setSaveCount(nextCount); + setIsSavedLocal(nextSaved); + saveMutationRef.current = { value: nextSaved, ts: Date.now() }; await toggleSave(id); - setSaveCount((prev) => Math.max(0, prev + (wasSaved ? -1 : 1))); + } catch { + setSaveCount(prevCount); + setIsSavedLocal(wasSaved); } finally { + saveMutationRef.current = null; setIsSaving(false); } }; @@ -259,12 +302,12 @@ export function Post({ Animated.sequence([ Animated.timing(likeScale, { toValue: 1.12, - duration: 140, + duration: 90, useNativeDriver: true, }), Animated.timing(likeScale, { toValue: 1, - duration: 160, + duration: 110, useNativeDriver: true, }), ]).start(); @@ -384,8 +427,16 @@ export function Post({ ]); }; + const handleOpenPost = () => { + router.push({ + pathname: "/post/[postId]", + params: { postId: String(id) }, + }); + }; + return ( - {resolvedUserPicture ? ( - @@ -418,18 +469,6 @@ export function Post({ - {canEdit && !isEditing ? ( - [ - styles.iconButton, - pressed && styles.iconButtonPressed, - ]} - disabled={isUpdating} - > - - - ) : null} {isEditing ? ( @@ -536,7 +575,23 @@ export function Post({ ) : ( - {localContent} + + + {previewContent} + {isTruncated ? ( + + ) : null} + + )} @@ -557,21 +612,16 @@ export function Post({ - router.push({ - pathname: "/post/[postId]", - params: { postId: String(id) }, - }) - } + onPress={handleOpenPost} /> - + ); } @@ -702,4 +752,18 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 6, }, + contentWrapper: { + position: "relative", + }, + contentClamped: { + maxHeight: 180, + overflow: "hidden", + }, + contentFade: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: 32, + }, }); diff --git a/frontend/components/ProjectCard.tsx b/frontend/components/ProjectCard.tsx index e27e312..9a67e10 100644 --- a/frontend/components/ProjectCard.tsx +++ b/frontend/components/ProjectCard.tsx @@ -7,6 +7,7 @@ import { TagChip } from "@/components/TagChip"; import { useAppColors } from "@/hooks/useAppColors"; import { useAuth } from "@/contexts/AuthContext"; import { useSavedStreams } from "@/contexts/SavedStreamsContext"; +import { LazyFadeIn } from "@/components/LazyFadeIn"; import { isProjectLiked, likeProject, unlikeProject } from "@/services/api"; import { emitProjectStats, @@ -41,9 +42,11 @@ export function ProjectCard({ const [isSaved, setIsSaved] = useState(Boolean(saved)); const [isSaving, setIsSaving] = useState(false); const [saveCount, setSaveCount] = useState(project.saves ?? 0); + const likeMutationRef = useRef<{ value: boolean; ts: number } | null>(null); const pendingEmitRef = useRef<{ likes?: number; saves?: number; + isLiked?: boolean; } | null>(null); const likeScale = useRef(new Animated.Value(1)).current; @@ -72,6 +75,14 @@ export function ProjectCard({ try { const status = await isProjectLiked(user.username, project.id); if (isMounted) { + const mutation = likeMutationRef.current; + if ( + mutation && + mutation.value === status.status && + Date.now() - mutation.ts < 2000 + ) { + return; + } setIsLiked(status.status); } } catch { @@ -91,28 +102,31 @@ export function ProjectCard({ return; } setIsUpdating(true); + const nextLiked = !isLiked; + const nextLikes = Math.max(0, likeCount + (nextLiked ? 1 : -1)); + likeMutationRef.current = { value: nextLiked, ts: Date.now() }; + setIsLiked(nextLiked); + setLikeCount(nextLikes); + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + likes: nextLikes, + isLiked: nextLiked, + }; try { - if (isLiked) { - await unlikeProject(user.username, project.id); - setIsLiked(false); - const nextLikes = Math.max(0, likeCount - 1); - setLikeCount(nextLikes); - pendingEmitRef.current = { - ...(pendingEmitRef.current ?? {}), - likes: nextLikes, - isLiked: false, - }; - } else { + if (nextLiked) { await likeProject(user.username, project.id); - setIsLiked(true); - const nextLikes = likeCount + 1; - setLikeCount(nextLikes); - pendingEmitRef.current = { - ...(pendingEmitRef.current ?? {}), - likes: nextLikes, - isLiked: true, - }; + } else { + await unlikeProject(user.username, project.id); } + } catch { + const rollbackLikes = likeCount; + setIsLiked(isLiked); + setLikeCount(rollbackLikes); + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + likes: rollbackLikes, + isLiked, + }; } finally { setIsUpdating(false); } @@ -123,19 +137,25 @@ export function ProjectCard({ return; } setIsSaving(true); + const nextSaved = !isSaved; + const nextCount = Math.max(0, saveCount + (nextSaved ? 1 : -1)); + setIsSaved(nextSaved); + setSaveCount(nextCount); + pendingEmitRef.current = { + ...(pendingEmitRef.current ?? {}), + saves: nextCount, + }; try { - const nextSaved = !isSaved; - const nextCount = Math.max(0, saveCount + (nextSaved ? 1 : -1)); await toggleSave(project.id); - setIsSaved(nextSaved); - setSaveCount(nextCount); + onSavedChange?.(nextSaved); + } catch { + const fallbackSaved = isStreamSaved(project.id); + setIsSaved(fallbackSaved); + setSaveCount(saveCount); pendingEmitRef.current = { ...(pendingEmitRef.current ?? {}), - saves: nextCount, + saves: saveCount, }; - onSavedChange?.(nextSaved); - } catch { - setIsSaved(isStreamSaved(project.id)); } finally { setIsSaving(false); } @@ -187,104 +207,106 @@ export function ProjectCard({ }, [likeCount, project.id, saveCount]); return ( - - router.push({ - pathname: "/stream/[projectId]", - params: { projectId: String(project.id) }, - }) - } - style={[ - styles.card, - variant === "compact" && styles.cardCompact, - variant === "full" && styles.cardFull, - { backgroundColor: colors.surface, borderColor: colors.border }, - ]} - > - - - - {project.name} - - - {project.stage.toUpperCase()} · {project.contributors} builders - + + + router.push({ + pathname: "/stream/[projectId]", + params: { projectId: String(project.id) }, + }) + } + style={[ + styles.card, + variant === "compact" && styles.cardCompact, + variant === "full" && styles.cardFull, + { backgroundColor: colors.surface, borderColor: colors.border }, + ]} + > + + + + {project.name} + + + {project.stage.toUpperCase()} · {project.contributors} builders + + + + + + {project.summary} + + + {isCreator ? ( + + ) : isBuilder ? ( + + ) : null} + {project.tags.map((tag) => ( + + ))} - - - - {project.summary} - - - {isCreator ? ( - - ) : isBuilder ? ( - - ) : null} - {project.tags.map((tag) => ( - - ))} - - - + + + [ + styles.metaItem, + isLiked && { + shadowColor: colors.tint, + shadowOpacity: 0.35, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + elevation: 2, + }, + pressed && styles.metaPressed, + ]} + onPress={handleLikeToggle} + > + + + {likeCount} + + + [ styles.metaItem, - isLiked && { - shadowColor: colors.tint, - shadowOpacity: 0.35, - shadowRadius: 6, - shadowOffset: { width: 0, height: 0 }, - elevation: 2, - }, pressed && styles.metaPressed, ]} - onPress={handleLikeToggle} + onPress={handleSaveToggle} + disabled={false} > - {likeCount} + {saveCount} - - [ - styles.metaItem, - pressed && styles.metaPressed, - ]} - onPress={handleSaveToggle} - disabled={false} - > - - - {saveCount} - - - - - - {new Date(project.updated_on).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} - + + + + {new Date(project.updated_on).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + + - - + + ); } diff --git a/frontend/components/ScrollView.tsx b/frontend/components/ScrollView.tsx index 65aa67c..062bd13 100644 --- a/frontend/components/ScrollView.tsx +++ b/frontend/components/ScrollView.tsx @@ -41,7 +41,8 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - padding: 32, + paddingVertical: 32, + paddingHorizontal: 0, gap: 16, overflow: "hidden", }, diff --git a/frontend/components/SectionHeader.tsx b/frontend/components/SectionHeader.tsx index bdfeb37..22024c8 100644 --- a/frontend/components/SectionHeader.tsx +++ b/frontend/components/SectionHeader.tsx @@ -50,7 +50,8 @@ const styles = StyleSheet.create({ action: { flexDirection: "row", alignItems: "center", - gap: 4, + gap: 6, + marginRight: 16, }, pressed: { opacity: 0.8, diff --git a/frontend/components/TopBlur.tsx b/frontend/components/TopBlur.tsx index 7e12c9e..ecfffbd 100644 --- a/frontend/components/TopBlur.tsx +++ b/frontend/components/TopBlur.tsx @@ -1,23 +1,51 @@ -import React from "react"; -import { StyleSheet } from "react-native"; +import React, { useMemo } from "react"; +import { Animated, StyleSheet } from "react-native"; import { BlurView } from "expo-blur"; +import { LinearGradient } from "expo-linear-gradient"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useColorScheme } from "@/hooks/useColorScheme"; -export function TopBlur() { +type TopBlurProps = { + scrollY?: Animated.Value; +}; + +export function TopBlur({ scrollY }: TopBlurProps) { const insets = useSafeAreaInsets(); const theme = useColorScheme() ?? "light"; - const height = Math.max(insets.top, 12) + 8; - const backgroundColor = - theme === "dark" ? "rgba(5, 8, 5, 0.7)" : "rgba(7, 11, 7, 0.7)"; + const height = Math.max(insets.top, 12) + 28; + const veilColors = useMemo(() => { + if (theme === "dark") { + return ["rgba(0, 0, 0, 0.24)", "rgba(0, 0, 0, 0.12)", "rgba(0, 0, 0, 0)"]; + } + return [ + "rgba(255, 255, 255, 0.2)", + "rgba(255, 255, 255, 0.1)", + "rgba(255, 255, 255, 0)", + ]; + }, [theme]); + const veilStops = [0, 0.5, 1]; + const opacity = scrollY + ? scrollY.interpolate({ + inputRange: [0, 60], + outputRange: [0, 1], + extrapolate: "clamp", + }) + : 0; return ( - + style={[styles.blur, { height, opacity }]} + > + + + ); } @@ -28,5 +56,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, zIndex: 2, + backgroundColor: "transparent", + overflow: "hidden", }, }); diff --git a/frontend/components/User.tsx b/frontend/components/User.tsx index 281c09d..1b6a143 100644 --- a/frontend/components/User.tsx +++ b/frontend/components/User.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { Alert, Image, Modal, Pressable, StyleSheet, View } from "react-native"; +import { Alert, Modal, Pressable, StyleSheet, View } from "react-native"; import * as Linking from "expo-linking"; +import { FadeInImage } from "@/components/FadeInImage"; import { ThemedText } from "@/components/ThemedText"; import { MarkdownText } from "@/components/MarkdownText"; import { UserProps } from "@/constants/Types"; @@ -67,7 +68,10 @@ export default function User({ onPress={() => setIsImageOpen(true)} style={styles.avatarButton} > - + ) : ( {hasPicture ? ( - {resolvedPicture ? ( - diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx index 3ac9dd8..1952906 100644 --- a/frontend/components/header.tsx +++ b/frontend/components/header.tsx @@ -24,7 +24,7 @@ export function MyHeader() { type="caption" style={[styles.tagline, { color: colors.muted }]} > - bytes, streams, and late-night commits + A Pace for Devs @@ -106,6 +106,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", gap: 10, + marginRight: 24, }, iconButton: { width: 34, diff --git a/frontend/contexts/SavedContext.tsx b/frontend/contexts/SavedContext.tsx index d464625..342db79 100644 --- a/frontend/contexts/SavedContext.tsx +++ b/frontend/contexts/SavedContext.tsx @@ -58,24 +58,24 @@ export function SavedProvider({ children }: { children: React.ReactNode }) { const toggleSave = async (postId: number) => { const isAlreadySaved = savedPostIds.includes(postId); + const nextIds = isAlreadySaved + ? savedPostIds.filter((id) => id !== postId) + : [...savedPostIds, postId]; + setSavedPostIds(nextIds); if (user?.username) { - if (isAlreadySaved) { - await unsavePost(user.username, postId); - setSavedPostIds((prev) => prev.filter((id) => id !== postId)); - } else { - await savePost(user.username, postId); - setSavedPostIds((prev) => [...prev, postId]); + try { + if (isAlreadySaved) { + await unsavePost(user.username, postId); + } else { + await savePost(user.username, postId); + } + } catch { + setSavedPostIds(savedPostIds); } return; } - setSavedPostIds((prev) => { - const next = prev.includes(postId) - ? prev.filter((id) => id !== postId) - : [...prev, postId]; - SecureStore.setItemAsync(SAVED_KEY, JSON.stringify(next)); - return next; - }); + SecureStore.setItemAsync(SAVED_KEY, JSON.stringify(nextIds)); }; const value = useMemo( diff --git a/frontend/contexts/SavedStreamsContext.tsx b/frontend/contexts/SavedStreamsContext.tsx index 6f8429a..ab01f77 100644 --- a/frontend/contexts/SavedStreamsContext.tsx +++ b/frontend/contexts/SavedStreamsContext.tsx @@ -71,35 +71,24 @@ export function SavedStreamsProvider({ const toggleSave = async (projectId: number) => { const isAlreadySaved = savedProjectIds.includes(projectId); + const nextIds = isAlreadySaved + ? normalizeIds(savedProjectIds.filter((id) => id !== projectId)) + : normalizeIds([...savedProjectIds, projectId]); + setSavedProjectIds(nextIds); if (user?.username) { - if (isAlreadySaved) { - await unfollowProject(user.username, projectId); - setSavedProjectIds((prev) => - normalizeIds(prev.filter((id) => id !== projectId)), - ); - } else { - await followProject(user.username, projectId); - setSavedProjectIds((prev) => normalizeIds([...prev, projectId])); - } - try { - const ids = await getProjectFollowing(user.username); - setSavedProjectIds(Array.isArray(ids) ? normalizeIds(ids) : []); + if (isAlreadySaved) { + await unfollowProject(user.username, projectId); + } else { + await followProject(user.username, projectId); + } } catch { - // Keep optimistic state if refresh fails. + setSavedProjectIds(savedProjectIds); } return; } - setSavedProjectIds((prev) => { - const next = normalizeIds( - prev.includes(projectId) - ? prev.filter((id) => id !== projectId) - : [...prev, projectId], - ); - SecureStore.setItemAsync(SAVED_STREAMS_KEY, JSON.stringify(next)); - return next; - }); + SecureStore.setItemAsync(SAVED_STREAMS_KEY, JSON.stringify(nextIds)); }; const removeSavedProjectIds = async (projectIds: number[]) => { diff --git a/frontend/hooks/useDeferredRender.ts b/frontend/hooks/useDeferredRender.ts new file mode 100644 index 0000000..f0064af --- /dev/null +++ b/frontend/hooks/useDeferredRender.ts @@ -0,0 +1,45 @@ +import { InteractionManager } from "react-native"; +import { useEffect, useState } from "react"; + +type DeferredOptions = { + enabled?: boolean; + delayMs?: number; +}; + +export function useDeferredRender({ enabled = true, delayMs = 0 }: DeferredOptions = {}) { + const [ready, setReady] = useState(!enabled); + + useEffect(() => { + if (!enabled) { + setReady(true); + return; + } + + let cancelled = false; + let timeout: ReturnType | null = null; + const task = InteractionManager.runAfterInteractions(() => { + if (cancelled) { + return; + } + if (delayMs > 0) { + timeout = setTimeout(() => { + if (!cancelled) { + setReady(true); + } + }, delayMs); + return; + } + setReady(true); + }); + + return () => { + cancelled = true; + if (timeout) { + clearTimeout(timeout); + } + task?.cancel?.(); + }; + }, [delayMs, enabled]); + + return ready; +} diff --git a/frontend/hooks/useTopBlurScroll.ts b/frontend/hooks/useTopBlurScroll.ts new file mode 100644 index 0000000..9372e47 --- /dev/null +++ b/frontend/hooks/useTopBlurScroll.ts @@ -0,0 +1,21 @@ +import { useMemo, useRef } from "react"; +import { Animated } from "react-native"; + +type TopBlurScroll = { + scrollY: Animated.Value; + onScroll: (event: any) => void; +}; + +export function useTopBlurScroll(): TopBlurScroll { + const scrollY = useRef(new Animated.Value(0)).current; + const onScroll = useMemo( + () => + Animated.event( + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + { useNativeDriver: true }, + ), + [scrollY], + ); + + return { scrollY, onScroll }; +}