Complete new group flow and speed up groups loading

This commit is contained in:
Adolfo Reyna
2026-02-20 21:35:54 -05:00
parent 83604f5eaa
commit 01aeedf950
4 changed files with 337 additions and 35 deletions

5
API.js
View File

@@ -343,12 +343,13 @@ const API = {
return postCall("/user/setData", {key, value}); return postCall("/user/setData", {key, value});
}, },
//Groups //Groups
newGroup(title, subtitle, description, isPrivate=false, isCourse=false) { newGroup(title, subtitle, description, isPrivate=false, isCourse=false, photo='') {
return postCall("/user/groups", { return postCall("/user/groups", {
profile: { profile: {
firstName: title, firstName: title,
lastName: subtitle, lastName: subtitle,
description description,
photo
}, },
isPrivate, isPrivate,
isCourse isCourse

View File

@@ -1,24 +1,57 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { Searchbar, Title, IconButton } from 'react-native-paper'; import { Searchbar, Title, IconButton } from 'react-native-paper';
import { StyleSheet, SafeAreaView, FlatList, View } from 'react-native'; import { StyleSheet, SafeAreaView, FlatList, View, ActivityIndicator } from 'react-native';
import API from "../API"; import API from "../API";
import GroupCard from "../components/GroupCard"; import GroupCard from "../components/GroupCard";
import AsyncStorage from '@react-native-async-storage/async-storage';
const GROUPS_CACHE_KEY = 'groups_following';
const storeGroupsCache = async (value) => {
try {
const jsonValue = JSON.stringify(value || []);
await AsyncStorage.setItem(GROUPS_CACHE_KEY, jsonValue);
} catch (e) {
}
};
const getGroupsCache = async () => {
try {
const value = await AsyncStorage.getItem(GROUPS_CACHE_KEY);
if (value !== null) return JSON.parse(value);
return [];
} catch (e) {
return [];
}
};
const Groups = ({navigation}) => { const Groups = ({navigation}) => {
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [searchVisible, setSearchVisible] = React.useState(false); const [searchVisible, setSearchVisible] = React.useState(false);
const [groups, setGroups] = React.useState([]); const [groups, setGroups] = React.useState([]);
const [queryTimer, setQueryTimer] = React.useState(0); const [loading, setLoading] = React.useState(true);
const searchTextBox = useRef(null); const searchTextBox = useRef(null);
const queryTimer = useRef(null);
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
API.getFollowingGroups('').then((data) => { const getData = async () => {
if (subscribed) const cached = await getGroupsCache();
setGroups(data?.groups || []); if (subscribed) setGroups(Array.isArray(cached) ? cached : []);
}); API.getFollowingGroups('').then((data) => {
if (!subscribed) return;
const liveGroups = Array.isArray(data?.groups) ? data.groups : [];
setGroups(liveGroups);
storeGroupsCache(liveGroups);
setLoading(false);
}).catch(() => {
if (subscribed) setLoading(false);
});
};
getData();
return () => { return () => {
subscribed = false; subscribed = false;
if (queryTimer.current) clearTimeout(queryTimer.current);
} }
}, []) }, [])
@@ -26,19 +59,23 @@ const Groups = ({navigation}) => {
const onChangeSearch = query => { const onChangeSearch = query => {
setSearchQuery(query); setSearchQuery(query);
if (queryTimer) clearTimeout(queryTimer); if (queryTimer.current) clearTimeout(queryTimer.current);
let timerId = setTimeout(() => { setLoading(true);
queryTimer.current = setTimeout(() => {
if (!query) { if (!query) {
return API.getFollowingGroups('').then((data) => { return API.getFollowingGroups('').then((data) => {
setGroups(data.groups || []); const followingGroups = Array.isArray(data?.groups) ? data.groups : [];
setGroups(followingGroups);
storeGroupsCache(followingGroups);
setLoading(false);
}); });
} }
API.searchGroups(query).then((data) => { API.searchGroups(query).then((data) => {
setGroups(data.groups || []); setGroups(Array.isArray(data?.groups) ? data.groups : []);
setLoading(false);
}) })
}, 300); }, 300);
setQueryTimer(timerId);
}; };
const renderProfile = (({ item }) => { const renderProfile = (({ item }) => {
return (<GroupCard profileObj={item} />); return (<GroupCard profileObj={item} />);
@@ -93,6 +130,9 @@ const Groups = ({navigation}) => {
} }
style={{backgroundColor: "#edf2f7",}} style={{backgroundColor: "#edf2f7",}}
/> />
{loading && !groups.length ? (
<ActivityIndicator style={styles.loader} />
) : <></>}
</SafeAreaView> </SafeAreaView>
) )
} }
@@ -110,5 +150,10 @@ const styles = StyleSheet.create({
marginTop: 15, marginTop: 15,
fontWeight: "bold", fontWeight: "bold",
color: "#777" color: "#777"
},
loader: {
position: "absolute",
alignSelf: "center",
top: 90,
} }
}); });

View File

@@ -1,39 +1,227 @@
import React, { useEffect } from "react"; import React from "react";
import { Searchbar, Title, IconButton } from 'react-native-paper'; import { StyleSheet, SafeAreaView, ImageBackground, View, ScrollView, Alert, Image, Platform } from 'react-native';
import { StyleSheet, SafeAreaView, ImageBackground, View } from 'react-native'; import { Title, TextInput, Button, Text, Switch } from 'react-native-paper';
import API from "../API"; import API from "../API";
import GroupCard from "../components/GroupCard"; import * as ImagePicker from 'expo-image-picker';
const NewGroup = () => { const DefaultPhoto = "https://social.emmint.com/uploads/e6f9be6d665dc43417701bf16a90122c.png";
const NewGroup = ({ navigation }) => {
const [title, setTitle] = React.useState('');
const [subtitle, setSubtitle] = React.useState('');
const [description, setDescription] = React.useState('');
const [isPrivate, setIsPrivate] = React.useState(false);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [selectedPhoto, setSelectedPhoto] = React.useState(null);
useEffect(() => { const pickImage = async () => {
}, []) if (isSubmitting) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.6,
});
if (!result.canceled && result.assets?.[0]) {
setSelectedPhoto(result.assets[0]);
}
};
const handleUploadPhoto = async (photo) => {
if (!photo) return '';
const uri = Platform.OS === "android" ? photo.uri : photo.uri.replace("file://", "");
const filename = photo.uri.split("/").pop() || "image.jpg";
const match = /\.(\w+)$/.exec(filename);
const ext = match?.[1] || "jpg";
const type = match ? `image/${match[1]}` : "image/jpeg";
const formData = new FormData();
formData.append("banner", {
uri,
name: `image.${ext}`,
type,
});
try {
const uploaded = await fetch("https://social.emmint.com/upload.php", {
method: "POST",
body: formData,
headers: { "Content-Type": "multipart/form-data" }
})
.then((res) => res.json())
.then((data) => data?.fileName || '');
return uploaded;
} catch (error) {
return '';
}
};
const createGroup = async () => {
const cleanTitle = title.trim();
const cleanSubtitle = subtitle.trim();
const cleanDescription = description.trim();
if (!cleanTitle) {
return Alert.alert("Missing name", "Please add a group name.");
}
if (!cleanDescription) {
return Alert.alert("Missing description", "Please add a short group description.");
}
setIsSubmitting(true);
try {
const photoPath = selectedPhoto ? await handleUploadPhoto(selectedPhoto) : '';
if (selectedPhoto && !photoPath) {
return Alert.alert("Could not upload image", "Please try again.");
}
const data = await API.newGroup(cleanTitle, cleanSubtitle, cleanDescription, isPrivate, false, photoPath);
if (data?.status !== "ok") {
return Alert.alert("Could not create group", data?.status || "Please try again.");
}
setTitle('');
setSubtitle('');
setDescription('');
setIsPrivate(false);
setSelectedPhoto(null);
if (data?._id) {
return navigation.replace("Profile", { profileid: data._id });
}
Alert.alert(
"Group created",
"Your new group is ready. You can switch to it from the menu.",
[{ text: "OK", onPress: () => navigation.goBack() }]
);
} catch (error) {
Alert.alert("Could not create group", "Please try again.");
} finally {
setIsSubmitting(false);
}
};
return ( return (
<SafeAreaView style={{ padding: 10, backgroundColor: "#edf2f7", flex:1 }}> <SafeAreaView style={styles.safeArea}>
<ImageBackground source={require("../assets/settings.png")} <ImageBackground
style={{flex:1}} source={require("../assets/settings.png")}
imageStyle={{resizeMode:"contain", opacity: 0.05}} style={styles.background}
imageStyle={styles.backgroundImage}
> >
<Title style={styles.title}>New Group:</Title> <ScrollView contentContainerStyle={styles.container}>
<Title style={styles.title}>New Group</Title>
<View style={styles.photoRow}>
<Image
source={{ uri: selectedPhoto?.uri || DefaultPhoto }}
style={styles.photoPreview}
/>
<Button mode="outlined" icon="photo" onPress={pickImage} disabled={isSubmitting}>
{selectedPhoto ? "Change image" : "Add group image"}
</Button>
</View>
<TextInput
mode="outlined"
label="Group name"
value={title}
onChangeText={setTitle}
style={styles.input}
/>
<TextInput
mode="outlined"
label="Subtitle (optional)"
value={subtitle}
onChangeText={setSubtitle}
style={styles.input}
/>
<TextInput
mode="outlined"
label="Description"
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
style={styles.input}
/>
<View style={styles.switchRow}>
<View style={styles.switchTextWrap}>
<Text style={styles.switchTitle}>Private group</Text>
<Text style={styles.switchSubtitle}>Require approval before people can join.</Text>
</View>
<Switch value={isPrivate} onValueChange={setIsPrivate} />
</View>
<Button
mode="contained"
onPress={createGroup}
loading={isSubmitting}
disabled={isSubmitting}
style={styles.button}
>
Create Group
</Button>
</ScrollView>
</ImageBackground> </ImageBackground>
</SafeAreaView> </SafeAreaView>
) );
} };
export default NewGroup; export default NewGroup;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safeArea: {
backgroundColor: "#edf2f7",
flex: 1,
},
background: {
flex: 1,
},
backgroundImage: {
resizeMode: "contain",
opacity: 0.05,
},
container: { container: {
padding: 5, padding: 14,
paddingBottom: 24,
},
photoRow: {
alignItems: "center",
marginTop: 10,
marginBottom: 4,
},
photoPreview: {
width: 96,
height: 96,
borderRadius: 48,
marginBottom: 10,
backgroundColor: "#ddd",
}, },
title: { title: {
padding: 10, paddingBottom: 8,
paddingTop:5,
fontSize: 30, fontSize: 30,
marginTop: 15, marginTop: 8,
fontWeight: "bold", fontWeight: "bold",
color: "#777" color: "#777",
} },
input: {
marginTop: 10,
backgroundColor: "#fff",
},
switchRow: {
marginTop: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
switchTextWrap: {
flex: 1,
},
switchTitle: {
fontSize: 16,
fontWeight: "600",
},
switchSubtitle: {
color: "#666",
marginTop: 2,
},
button: {
marginTop: 22,
},
}); });

View File

@@ -1,12 +1,14 @@
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, ActivityIndicator, StyleSheet, SafeAreaView, FlatList } from 'react-native'; import { View, ActivityIndicator, StyleSheet, SafeAreaView, FlatList, Alert } from 'react-native';
import { Button, IconButton } from 'react-native-paper'; import { Button, IconButton } from 'react-native-paper';
import API from './../API.js'; import API from './../API.js';
import Post from './../components/Post.js'; import Post from './../components/Post.js';
import NewPost from "./../components/NewPost.js"; import NewPost from "./../components/NewPost.js";
import ProfileHeader from '../components/ProfileHeader.js'; import ProfileHeader from '../components/ProfileHeader.js';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSnapshot } from 'valtio';
import GlobalState from '../contexts/GlobalState.js';
const PROFILE_LOG_PREFIX = '[Profile]'; const PROFILE_LOG_PREFIX = '[Profile]';
const logProfile = (...args) => { const logProfile = (...args) => {
@@ -35,11 +37,19 @@ const getProfilePosts = async (profileid) => {
} }
let Profile = ({ navigation, route }) => { let Profile = ({ navigation, route }) => {
const viewer = useSnapshot(GlobalState).me || {};
let [Posts, setPosts] = useState([]); let [Posts, setPosts] = useState([]);
let [profile, setProfile] = useState({}); let [profile, setProfile] = useState({});
const [showNewPost, setShowNewPost] = useState(false); const [showNewPost, setShowNewPost] = useState(false);
const [tag, setTag] = useState(''); const [tag, setTag] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isDeletingGroup, setIsDeletingGroup] = useState(false);
const isOwnedGroup = !!(
profile?._id &&
profile?.isGroup &&
String(profile?.userid || '') === String(viewer?.userid || '')
);
useEffect(() => { useEffect(() => {
let subscribed = true; let subscribed = true;
@@ -128,6 +138,46 @@ let Profile = ({ navigation, route }) => {
return (<></>); return (<></>);
return (<Post post={item} />); return (<Post post={item} />);
}); });
const handleDeleteGroup = () => {
if (!profile?._id || isDeletingGroup) return;
Alert.alert(
"Delete group?",
"This will permanently delete this group profile.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
setIsDeletingGroup(true);
try {
const result = await API.deleteProfile(profile._id);
if (result?.status !== "ok") {
return Alert.alert("Could not delete group", result?.status || "Please try again.");
}
const me = await API.getMe();
if (me && me._id) GlobalState.me = me;
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.reset({
index: 0,
routes: [{ name: 'MainNavigation' }],
});
}
} catch (error) {
Alert.alert("Could not delete group", "Please try again.");
} finally {
setIsDeletingGroup(false);
}
}
}
]
);
};
const header = ( const header = (
<View> <View>
<ProfileHeader profileObj={profile} key={profile._id} /> <ProfileHeader profileObj={profile} key={profile._id} />
@@ -166,6 +216,20 @@ let Profile = ({ navigation, route }) => {
setTag('embedded'); setTag('embedded');
}}>{tag == 'embedded' ? "Files" : ''}</Button> }}>{tag == 'embedded' ? "Files" : ''}</Button>
</View> </View>
{isOwnedGroup ? (
<View style={styles.deleteGroupRow}>
<Button
mode="contained"
icon="delete"
buttonColor="#b3261e"
loading={isDeletingGroup}
disabled={isDeletingGroup}
onPress={handleDeleteGroup}
>
Delete Group
</Button>
</View>
) : <></>}
{ showNewPost ? { showNewPost ?
<NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} /> <NewPost newPostCB={(newPost) => setPosts([newPost, ...Posts])} />
: <></> : <></>
@@ -220,4 +284,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: "#edf2f7", backgroundColor: "#edf2f7",
}, },
deleteGroupRow: {
paddingHorizontal: 12,
paddingTop: 12,
},
}); });