diff --git a/API.js b/API.js index 9b8689a..832a5af 100644 --- a/API.js +++ b/API.js @@ -343,12 +343,13 @@ const API = { return postCall("/user/setData", {key, value}); }, //Groups - newGroup(title, subtitle, description, isPrivate=false, isCourse=false) { + newGroup(title, subtitle, description, isPrivate=false, isCourse=false, photo='') { return postCall("/user/groups", { profile: { firstName: title, lastName: subtitle, - description + description, + photo }, isPrivate, isCourse diff --git a/Views/Groups.js b/Views/Groups.js index ffccd40..cd777f3 100644 --- a/Views/Groups.js +++ b/Views/Groups.js @@ -1,24 +1,57 @@ import React, { useEffect, useRef } from "react"; 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 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 [searchQuery, setSearchQuery] = React.useState(''); const [searchVisible, setSearchVisible] = React.useState(false); const [groups, setGroups] = React.useState([]); - const [queryTimer, setQueryTimer] = React.useState(0); + const [loading, setLoading] = React.useState(true); const searchTextBox = useRef(null); + const queryTimer = useRef(null); useEffect(() => { let subscribed = true; - API.getFollowingGroups('').then((data) => { - if (subscribed) - setGroups(data?.groups || []); - }); + const getData = async () => { + const cached = await getGroupsCache(); + 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 () => { subscribed = false; + if (queryTimer.current) clearTimeout(queryTimer.current); } }, []) @@ -26,19 +59,23 @@ const Groups = ({navigation}) => { const onChangeSearch = query => { setSearchQuery(query); - if (queryTimer) clearTimeout(queryTimer); - let timerId = setTimeout(() => { + if (queryTimer.current) clearTimeout(queryTimer.current); + setLoading(true); + queryTimer.current = setTimeout(() => { if (!query) { 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) => { - setGroups(data.groups || []); + setGroups(Array.isArray(data?.groups) ? data.groups : []); + setLoading(false); }) }, 300); - setQueryTimer(timerId); }; const renderProfile = (({ item }) => { return (); @@ -93,6 +130,9 @@ const Groups = ({navigation}) => { } style={{backgroundColor: "#edf2f7",}} /> + {loading && !groups.length ? ( + + ) : <>} ) } @@ -110,5 +150,10 @@ const styles = StyleSheet.create({ marginTop: 15, fontWeight: "bold", color: "#777" + }, + loader: { + position: "absolute", + alignSelf: "center", + top: 90, } }); diff --git a/Views/NewGroup.js b/Views/NewGroup.js index cc8db75..5f8190e 100644 --- a/Views/NewGroup.js +++ b/Views/NewGroup.js @@ -1,39 +1,227 @@ -import React, { useEffect } from "react"; -import { Searchbar, Title, IconButton } from 'react-native-paper'; -import { StyleSheet, SafeAreaView, ImageBackground, View } from 'react-native'; +import React from "react"; +import { StyleSheet, SafeAreaView, ImageBackground, View, ScrollView, Alert, Image, Platform } from 'react-native'; +import { Title, TextInput, Button, Text, Switch } from 'react-native-paper'; 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"; - useEffect(() => { - }, []) +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); + + 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 ( - - + - New Group: + + New Group + + + + + + + + + + Private group + Require approval before people can join. + + + + + - ) -} + ); +}; export default NewGroup; const styles = StyleSheet.create({ + safeArea: { + backgroundColor: "#edf2f7", + flex: 1, + }, + background: { + flex: 1, + }, + backgroundImage: { + resizeMode: "contain", + opacity: 0.05, + }, 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: { - padding: 10, - paddingTop:5, + paddingBottom: 8, fontSize: 30, - marginTop: 15, + marginTop: 8, 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, + }, }); diff --git a/Views/Profile.js b/Views/Profile.js index 1824304..7596bdf 100644 --- a/Views/Profile.js +++ b/Views/Profile.js @@ -1,12 +1,14 @@ import { StatusBar } from 'expo-status-bar'; 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 API from './../API.js'; import Post from './../components/Post.js'; import NewPost from "./../components/NewPost.js"; import ProfileHeader from '../components/ProfileHeader.js'; 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 logProfile = (...args) => { @@ -35,11 +37,19 @@ const getProfilePosts = async (profileid) => { } let Profile = ({ navigation, route }) => { + const viewer = useSnapshot(GlobalState).me || {}; let [Posts, setPosts] = useState([]); let [profile, setProfile] = useState({}); const [showNewPost, setShowNewPost] = useState(false); const [tag, setTag] = useState(''); const [loading, setLoading] = useState(true); + const [isDeletingGroup, setIsDeletingGroup] = useState(false); + + const isOwnedGroup = !!( + profile?._id && + profile?.isGroup && + String(profile?.userid || '') === String(viewer?.userid || '') + ); useEffect(() => { let subscribed = true; @@ -128,6 +138,46 @@ let Profile = ({ navigation, route }) => { return (<>); return (); }); + + 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 = ( @@ -166,6 +216,20 @@ let Profile = ({ navigation, route }) => { setTag('embedded'); }}>{tag == 'embedded' ? "Files" : ''} + {isOwnedGroup ? ( + + + + ) : <>} { showNewPost ? setPosts([newPost, ...Posts])} /> : <> @@ -220,4 +284,8 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: "#edf2f7", }, + deleteGroupRow: { + paddingHorizontal: 12, + paddingTop: 12, + }, });