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,
+ },
});