动态和主播推荐改为服务器逻辑;优化全屏看图功能,添加会员保存图片功能;更新icon;修复bug

This commit is contained in:
yezian 2024-03-15 20:14:22 +08:00
parent 46b93373ce
commit 789842a95d
14 changed files with 372 additions and 193 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -0,0 +1,191 @@
import {
View,
Text,
Modal,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import React, { useState, useCallback, useEffect } from "react";
import { ImageViewer as OriginImageViewer } from "react-native-image-zoom-viewer";
import { Icon } from "@rneui/themed";
import { useTailwind } from "tailwind-rn";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import saveImage from "../../utils/saveImage";
import Toast from "react-native-toast-message";
import { get } from "../../utils/storeInfo";
import { useNavigation } from "@react-navigation/native";
import MyModal from "../MyModal";
//isVisible boolean
//setIsVisible function
//imageUrls [{ url: string }]
//index int
export default function ImageViewer({
isVisible,
setIsVisible,
imageUrls,
index,
}) {
const tailwind = useTailwind();
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const [isSaved, setIsSaved] = useState(false);
const [isVip, setIsVip] = useState(false);
useEffect(() => {
const checkRole = async () => {
const account = await get("account");
const role = account.role;
const isVip = account.is_a_member;
if (role !== 0 || isVip === 1) {
setIsVip(true);
}
};
checkRole();
}, []);
const MenusComponent = useCallback(
({ cancel, saveToLocal }) => (
<Modal
visible={true}
transparent={true}
statusBarTranslucent
animationType="fade"
>
<TouchableOpacity
onPress={cancel}
activeOpacity={1}
style={tailwind("flex flex-1 bg-[#00000080]")}
>
<TouchableOpacity
activeOpacity={1}
style={{
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
...tailwind(
"absolute bottom-0 left-0 h-32 w-full bg-[#13121F] rounded-t-2xl"
),
}}
>
<View style={tailwind("flex flex-1 flex-row items-center px-4")}>
<TouchableOpacity
onPress={
isVip
? saveToLocal
: () => {
setIsVisible(false);
navigation.navigate("WebWithoutHeader", {
uri: process.env.EXPO_PUBLIC_WEB_URL + "/vip",
});
}
}
style={tailwind("flex flex-col items-center px-4")}
>
<Icon type="ionicon" name="image" size={30} color="white" />
<Text
style={tailwind("text-sm text-[#FFFFFF80] font-medium mt-1")}
>
{isSaved ? "已保存" : "保存(会员特权)"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={cancel}
style={tailwind("flex flex-col items-center px-4")}
>
<Icon
type="ionicon"
name="close-circle"
size={30}
color="white"
/>
<Text
style={tailwind("text-sm text-[#FFFFFF80] font-medium mt-1")}
>
取消
</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
),
[isSaved, isVip]
);
const [isVipModalVisible, setIsVipModalVisible] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const hanldSaveImage = async (index) => {
if (!isVip) {
setIsVipModalVisible(true);
return;
}
if (isSaving) return;
setIsSaving(true);
const isSuccess = await saveImage(imageUrls[index].url);
setIsSaved(isSuccess);
setIsSaving(false);
};
return (
<Modal visible={isVisible} statusBarTranslucent transparent={true}>
<OriginImageViewer
imageUrls={imageUrls}
index={index}
onClick={() => setIsVisible(false)}
onSwipeDown={() => setIsVisible(false)}
enableSwipeDown
backgroundColor="#07050A"
renderFooter={(index) => (
<TouchableOpacity
onPress={() => hanldSaveImage(index)}
style={{
marginLeft: 20,
marginBottom: insets.bottom,
...tailwind(
"flex justify-center items-center w-12 h-12 bg-[#FFFFFF1A] rounded-full"
),
}}
>
{isSaving && <ActivityIndicator size="small" />}
{!isSaving && (
<Icon
type="ionicon"
name="save-outline"
size={20}
color="white"
/>
)}
</TouchableOpacity>
)}
onSave={async (url) => {
const isSuccess = await saveImage(url);
setIsSaved(isSuccess);
}}
saveToLocalByLongPress={true}
menus={MenusComponent}
loadingRender={() => <ActivityIndicator size="large" />}
/>
<Toast />
<MyModal
visible={isVipModalVisible}
setVisible={setIsVipModalVisible}
title="是否开通会员?"
content="会员可无限制保存图片,一次开通永久有效。"
cancel={() => {
setIsVipModalVisible(false);
}}
confirm={() => {
setIsVipModalVisible(false);
setIsVisible(false);
navigation.navigate("WebWithoutHeader", {
uri: process.env.EXPO_PUBLIC_WEB_URL + "/vip",
});
}}
/>
</Modal>
);
}

View File

@ -4,14 +4,13 @@ import {
TouchableOpacity,
Modal,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
Image as NativeImage,
} from "react-native";
import React, { useEffect, useState } from "react";
import { useTailwind } from "tailwind-rn";
import { Image } from "expo-image";
import ImageViewer from "react-native-image-zoom-viewer";
import ImageViewer from "../ImageViewer";
import VideoModal from "../VideoModal";
import { useNavigation } from "@react-navigation/native";
import formatTimestamp from "../../utils/formatTimestamp";
@ -327,18 +326,12 @@ function ImageDisplay({ blur, media }) {
/>
</TouchableOpacity>
)}
<Modal visible={isModalVisible} statusBarTranslucent transparent={true}>
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
<ImageViewer
imageUrls={images}
enableSwipeDown
saveToLocalByLongPress={false}
onSwipeDown={() => setIsModalVisible(false)}
index={imageIndex}
loadingRender={() => <ActivityIndicator size="large" />}
/>
</TouchableWithoutFeedback>
</Modal>
<ImageViewer
isVisible={isModalVisible}
setIsVisible={setIsModalVisible}
imageUrls={images}
index={imageIndex}
/>
</View>
);
}

View File

@ -10,11 +10,14 @@ import React, { useState, useRef } from "react";
import { useTailwind } from "tailwind-rn";
import { Icon } from "@rneui/themed";
import { Video, ResizeMode } from "expo-av";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const screenWidth = Dimensions.get("window").width;
export default function VideoModal({ visible, setVisible, url }) {
const tailwind = useTailwind();
const insets = useSafeAreaInsets();
const [isReady, setIsReady] = useState(false);
const videoRef = useRef(null);
const [videoSize, setVideoSize] = useState({ width: 720, height: 1280 });
@ -32,13 +35,16 @@ export default function VideoModal({ visible, setVisible, url }) {
...tailwind("flex-1 justify-center items-center"),
}}
>
<View style={tailwind("absolute top-10 right-4 z-10")}>
<Icon
type="ionicon"
name="close-circle-outline"
size={40}
color="#f9fafb"
/>
<View
style={{
top: insets.top,
right: 20,
...tailwind(
"absolute z-10 flex justify-center items-center w-12 h-12 bg-[#FFFFFF1A] rounded-full"
),
}}
>
<Icon type="ionicon" name="close" size={24} color="#f9fafb" />
</View>
<TouchableOpacity activeOpacity={1}>
{!isReady && <ActivityIndicator size="large" />}

View File

@ -98,7 +98,6 @@ export default function SetPassword({ navigation, route }) {
}
);
const newData = await response.json();
console.log("base", base);
if (newData.ret === -1) {
Toast.show({
type: "error",

View File

@ -5,6 +5,7 @@ import {
Image as NativeImage,
Text,
TouchableOpacity,
FlatList,
} from "react-native";
import React, { useState, useEffect } from "react";
import Empty from "../../../components/Empty";
@ -12,7 +13,6 @@ import { useTailwind } from "tailwind-rn";
import baseRequest from "../../../utils/baseRequest";
import Toast from "react-native-toast-message";
import { generateSignature } from "../../../utils/crypto";
import { FlashList } from "@shopify/flash-list";
import Post from "../../../components/Post";
import { useNavigation } from "@react-navigation/native";
import { get } from "../../../utils/storeInfo";
@ -136,45 +136,30 @@ export default function FeedPosts({ blur }) {
}
};
//
const [currentTime, setCurrentTime] = useState();
const getCurrentTime = async () => {
setCurrentTime(Math.floor(new Date().getTime() / 1000));
};
//
//
const [data, setData] = useState([]);
const [offset, setOffset] = useState(0);
const [more, setMore] = useState(1);
const [isActivityIndicatorShow, setIsActivityIndicatorShow] = useState(true);
const getData = async (type) => {
if (!currentTime) return;
if (data.length === 0 && type === "bottom") return;
if (!more && type === "bottom") {
setIsActivityIndicatorShow(false);
return;
}
setIsActivityIndicatorShow(true);
const [isFetching, setIsFetching] = useState(false);
const getData = async (type = 0) => {
//type 0- 1- 2-
if (isFetching) return;
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
setIsFetching(true);
try {
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
const base = await baseRequest();
const signature = await generateSignature({
ct_upper_bound: currentTime,
offset: offset,
limit: 4,
op_type: type,
...base,
});
const _response = await fetch(
`${apiUrl}/api/moment/list?signature=${signature}`,
`${apiUrl}/api/moment/recomm_list?signature=${signature}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ct_upper_bound: currentTime,
offset: offset,
limit: 4,
op_type: type,
...base,
}),
}
@ -188,48 +173,44 @@ export default function FeedPosts({ blur }) {
});
return;
}
if (type === "top") {
if (type === 1 || type === 2) {
const topPosts = await getTopPostsData();
setData((prev) => [...topPosts, ..._data.data.list]);
setData((prev) => [...topPosts, ..._data.data.recomm_list]);
setIsActivityIndicatorShow(true);
} else {
setData((prev) => [...prev, ..._data.data.list]);
setData((prev) => [...prev, ..._data.data.recomm_list]);
if (_data.data.recomm_list.length === 0)
setIsActivityIndicatorShow(false);
}
setOffset(_data.data.offset);
setMore(_data.data.more);
} catch (error) {
console.error(error);
} finally {
setIsFetching(false);
}
};
//
//
useEffect(() => {
getVipPrice();
getCurrentTime();
getData(2);
}, []);
//
useEffect(() => {
getData("top");
}, [currentTime]);
const tailwind = useTailwind();
const renderItem = ({ item }) => <Post isBlur={blur} data={item} />;
const [refreshing, setRefreshing] = useState(false);
//
const handleRefresh = async () => {
setRefreshing(true);
setOffset(0);
setMore(1);
await getCurrentTime();
await getData(1);
setRefreshing(false);
};
return (
<View style={tailwind("flex-1")}>
<FlashList
<FlatList
data={data}
extraData={blur}
renderItem={renderItem}
estimatedItemSize={310}
initialNumToRender={4}
refreshControl={
<RefreshControl
@ -239,7 +220,7 @@ export default function FeedPosts({ blur }) {
onRefresh={() => handleRefresh()}
/>
}
onEndReached={() => getData("bottom")}
onEndReached={() => getData(0)}
ListEmptyComponent={<Empty type="nodata" />}
ListFooterComponent={
<View>

View File

@ -4,6 +4,7 @@ import {
Image as NativeImage,
Text,
TouchableOpacity,
FlatList,
} from "react-native";
import React, { useState, useEffect } from "react";
import Empty from "../../../components/Empty";
@ -12,7 +13,6 @@ import baseRequest from "../../../utils/baseRequest";
import Toast from "react-native-toast-message";
import { get } from "../../../utils/storeInfo";
import { generateSignature } from "../../../utils/crypto";
import { FlashList } from "@shopify/flash-list";
import Post from "../../../components/Post";
import { useNavigation } from "@react-navigation/native";
@ -190,10 +190,9 @@ export default function FollowPosts({ blur }) {
return (
<View style={tailwind("flex-1")}>
<FlashList
<FlatList
data={data}
renderItem={renderItem}
estimatedItemSize={287}
initialNumToRender={4}
refreshControl={
<RefreshControl

View File

@ -14,6 +14,7 @@ import { get, save } from "../../utils/storeInfo";
import { useFocusEffect } from "@react-navigation/native";
import baseRequest from "../../utils/baseRequest";
import { generateSignature } from "../../utils/crypto";
import Toast from "react-native-toast-message";
export default function Posts({ navigation }) {
const tailwind = useTailwind();

View File

@ -17,17 +17,22 @@ import { FlashList } from "@shopify/flash-list";
import { Svg, Path } from "react-native-svg";
export default function FeedStream() {
//mid
const [recommMids, setRecommMids] = useState([]);
const getRecommMids = async () => {
if (!isEnd) return;
//
const [data, setData] = useState([]);
const [isActivityIndicatorShow, setIsActivityIndicatorShow] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const getData = async (type = 0) => {
//type 0- 1- 2-
if (isFetching) return;
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
setIsFetching(true);
try {
const base = await baseRequest();
const signature = await generateSignature({
op_type: type,
...base,
});
const response = await fetch(
const _response = await fetch(
`${apiUrl}/api/streamer/recomm_list?signature=${signature}`,
{
method: "POST",
@ -35,110 +40,39 @@ export default function FeedStream() {
"Content-Type": "application/json",
},
body: JSON.stringify({
op_type: type,
...base,
}),
}
);
const recommData = await response.json();
if (recommData.ret === -1) {
const _data = await _response.json();
if (_data.ret === -1) {
Toast.show({
type: "error",
text1: recommData.msg,
text1: _data.msg,
topOffset: 60,
});
return;
}
setIsEnd(false);
setRecommMids(recommData.data.recomm_list);
} catch (error) {
console.error(error);
}
};
//
const [startIndex, setStartIndex] = useState(0);
// ID
const batchSize = 4;
//
const [data, setData] = useState([]);
const [isEnd, setIsEnd] = useState(true);
const [isActivityIndicatorShow, setIsActivityIndicatorShow] = useState(true);
const [isFetching, setIsFetching] = useState(false);
const getData = async (type) => {
if (isFetching) return;
if (isEnd) return;
if (recommMids.length === 0) return;
const apiUrl = process.env.EXPO_PUBLIC_API_URL;
const batchStreamerMids = recommMids.slice(
startIndex,
startIndex + batchSize
);
if (batchStreamerMids.length === 0) {
if (type !== "top") {
setIsActivityIndicatorShow(false);
return;
}
setIsActivityIndicatorShow(true);
setIsEnd(true);
setStartIndex(0);
return;
}
setIsFetching((prev) => true);
try {
const base = await baseRequest();
const signature = await generateSignature({
mids: batchStreamerMids,
...base,
});
const detailResponse = await fetch(
`${apiUrl}/api/streamer/list_ext_by_mids?signature=${signature}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mids: batchStreamerMids,
...base,
}),
}
);
const detailData = await detailResponse.json();
if (detailData.ret === -1) {
Toast.show({
type: "error",
text1: detailData.msg,
topOffset: 60,
});
return;
}
const sortedData = detailData.data.list.sort((a, b) => b.fans - a.fans);
if (type === "top") {
setData((prev) => {
if (startIndex === 0) {
return sortedData;
} else return [...sortedData, ...prev];
});
if (type === 1) {
setData((prev) => _data.data.recomm_list);
setIsActivityIndicatorShow(true);
} else {
setData((prev) => [...prev, ...sortedData]);
setData((prev) => [...prev, ..._data.data.recomm_list]);
if (_data.data.recomm_list.length === 0)
setIsActivityIndicatorShow(false);
}
//
setStartIndex(startIndex + batchSize);
setIsFetching((prev) => false);
} catch (error) {
console.error(error);
} finally {
setIsFetching(false);
}
};
//mid
//
useEffect(() => {
getRecommMids();
}, [isEnd]);
//mid
useEffect(() => {
getData("top");
}, [recommMids]);
getData(2);
}, []);
const tailwind = useTailwind();
const renderItem = ({ item }) => <StreamerCard data={item} />;
@ -166,7 +100,7 @@ export default function FeedStream() {
startRotation();
flatListRef.current.scrollToOffset({ animated: false, offset: 0 });
setRefreshing(true);
await getData("top");
await getData(1);
setRefreshing(false);
};
@ -175,6 +109,7 @@ export default function FeedStream() {
<FlashList
ref={flatListRef}
data={data}
keyExtractor={(item) => item.mid}
renderItem={renderItem}
estimatedItemSize={287}
initialNumToRender={4}
@ -186,7 +121,7 @@ export default function FeedStream() {
onRefresh={() => handleRefresh()}
/>
}
onEndReached={() => getData("bottom")}
onEndReached={() => getData(0)}
ListEmptyComponent={<Empty type="nodata" />}
ListFooterComponent={
<View>
@ -195,7 +130,6 @@ export default function FeedStream() {
)}
</View>
}
keyExtractor={(item) => item.mid}
/>
<TouchableOpacity
style={tailwind(

View File

@ -4,7 +4,6 @@ import {
TouchableOpacity,
TouchableWithoutFeedback,
Modal,
ActivityIndicator,
Text,
Alert,
ScrollView,
@ -15,7 +14,7 @@ import React, { useState, useEffect } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTailwind } from "tailwind-rn";
import { Image } from "expo-image";
import ImageViewer from "react-native-image-zoom-viewer";
import ImageViewer from "../../components/ImageViewer";
import Swiper from "react-native-swiper";
import { Divider, Icon } from "@rneui/themed";
import VideoModal from "../../components/VideoModal";
@ -300,18 +299,12 @@ export default function StreamerProfile({ navigation, route }) {
url={data?.shorts?.videos[0]?.urls[0]}
/>
{/* 展示图片的modal */}
<Modal visible={imagesVisible} statusBarTranslucent transparent={true}>
<TouchableWithoutFeedback onPress={() => setImagesVisible(false)}>
<ImageViewer
imageUrls={imagesForImageViewer}
enableSwipeDown
saveToLocalByLongPress={false}
onSwipeDown={() => setImagesVisible(false)}
index={imageIndex}
loadingRender={() => <ActivityIndicator size="large" />}
/>
</TouchableWithoutFeedback>
</Modal>
<ImageViewer
isVisible={imagesVisible}
setIsVisible={setImagesVisible}
imageUrls={imagesForImageViewer}
index={imageIndex}
/>
</>
);
};

View File

@ -14,6 +14,10 @@
top: -6rem
}
.bottom-0 {
bottom: 0px
}
.bottom-12 {
bottom: 3rem
}
@ -277,6 +281,10 @@
height: 2.25rem
}
.h-\[1px\] {
height: 1px
}
.h-\[3px\] {
height: 3px
}
@ -293,8 +301,8 @@
height: 100%
}
.h-\[1px\] {
height: 1px
.h-48 {
height: 12rem
}
.w-1\/2 {
@ -475,6 +483,16 @@
border-bottom-right-radius: 0.25rem
}
.rounded-t-xl {
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem
}
.rounded-t-2xl {
border-top-left-radius: 1rem;
border-top-right-radius: 1rem
}
.border {
border-width: 1px
}
@ -618,6 +636,10 @@
background-color: rgb(250 204 21 / var(--tw-bg-opacity))
}
.bg-\[\#00000080\] {
background-color: #00000080
}
.p-0 {
padding: 0px
}

View File

@ -19,6 +19,11 @@
"top": -96
}
},
"bottom-0": {
"style": {
"bottom": 0
}
},
"bottom-12": {
"style": {
"bottom": 48
@ -352,6 +357,11 @@
"height": 36
}
},
"h-[1px]": {
"style": {
"height": 1
}
},
"h-[3px]": {
"style": {
"height": 3
@ -372,9 +382,9 @@
"height": "100%"
}
},
"h-[1px]": {
"h-48": {
"style": {
"height": 1
"height": 192
}
},
"w-1/2": {
@ -621,6 +631,18 @@
"borderBottomRightRadius": 4
}
},
"rounded-t-xl": {
"style": {
"borderTopLeftRadius": 12,
"borderTopRightRadius": 12
}
},
"rounded-t-2xl": {
"style": {
"borderTopLeftRadius": 16,
"borderTopRightRadius": 16
}
},
"border": {
"style": {
"borderTopWidth": 1,
@ -816,6 +838,11 @@
"backgroundColor": "rgb(250 204 21 / var(--tw-bg-opacity))"
}
},
"bg-[#00000080]": {
"style": {
"backgroundColor": "#00000080"
}
},
"p-0": {
"style": {
"paddingTop": 0,

31
utils/saveImage.js Normal file
View File

@ -0,0 +1,31 @@
import * as MediaLibrary from "expo-media-library";
import * as FileSystem from "expo-file-system";
import Toast from "react-native-toast-message";
export default async function saveImage(uri) {
const permission = await MediaLibrary.requestPermissionsAsync();
if (permission.granted) {
const timestamp = new Date().getTime();
const fileUri = FileSystem.cacheDirectory + `${timestamp}.png`;
try {
const res = await FileSystem.downloadAsync(uri, fileUri);
await MediaLibrary.saveToLibraryAsync(res.uri);
Toast.show({
type: "success",
text1: "已保存到相册",
topOffset: 60,
});
return true;
} catch (err) {
console.error("FS Err: ", err);
return false;
}
} else {
Toast.show({
type: "error",
text1: "保存失败请检查APP媒体权限",
topOffset: 60,
});
return false;
}
}

View File

@ -274,16 +274,18 @@ export async function uploadVideo(asset) {
//上传多个图片或者视频
export async function multiUpload(assets) {
let ids = { image_ids: [], video_ids: [] };
await Promise.all(
assets.map(async (asset) => {
if (asset.duration === 0) {
const id = await uploadImage(asset);
ids.image_ids.push(id);
} else {
const id = await uploadVideo(asset);
ids.video_ids.push(id);
}
})
);
return ids;
const tasks = assets.map(async (asset, index) => {
if (asset.duration === 0) {
const id = await uploadImage(asset);
ids.image_ids[index] = id;
} else {
const id = await uploadVideo(asset);
ids.video_ids[index] = id;
}
});
await Promise.all(tasks);
return {
image_ids: ids.image_ids.filter(Boolean),
video_ids: ids.video_ids.filter(Boolean),
};
}