diff --git a/android/app/src/main/java/com/nostros/classes/Event.java b/android/app/src/main/java/com/nostros/classes/Event.java index f19639f..4501e50 100644 --- a/android/app/src/main/java/com/nostros/classes/Event.java +++ b/android/app/src/main/java/com/nostros/classes/Event.java @@ -53,7 +53,7 @@ public class Event { if (kind.equals("0")) { saveUserMeta(database); } else if (kind.equals("1") || kind.equals("2")) { - saveNote(database, userPubKey, relayUrl); + saveNote(database, userPubKey); } else if (kind.equals("3")) { if (pubkey.equals(userPubKey)) { savePets(database); @@ -64,6 +64,8 @@ public class Event { saveDirectMessage(database); } else if (kind.equals("7")) { saveReaction(database); + } else if (kind.equals("40")) { + saveGroup(database); } } catch (JSONException e) { e.printStackTrace(); @@ -185,7 +187,7 @@ public class Event { return false; } - protected void saveNote(SQLiteDatabase database, String userPubKey, String relayUrl) { + protected void saveNote(SQLiteDatabase database, String userPubKey) { ContentValues values = new ContentValues(); values.put("id", id); values.put("content", content); @@ -201,6 +203,23 @@ public class Event { database.replace("nostros_notes", null, values); } + protected void saveGroup(SQLiteDatabase database) throws JSONException { + JSONObject groupContent = new JSONObject(content); + + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("content", content); + values.put("created_at", created_at); + values.put("kind", kind); + values.put("pubkey", pubkey); + values.put("sig", sig); + values.put("tags", tags.toString()); + values.put("name", groupContent.optString("name")); + values.put("about", groupContent.optString("about")); + values.put("picture", groupContent.optString("picture")); + database.replace("nostros_groups", null, values); + } + protected void saveDirectMessage(SQLiteDatabase database) throws JSONException { JSONArray tag = tags.getJSONArray(0); ArrayList identifiers = new ArrayList<>(); diff --git a/android/app/src/main/java/com/nostros/modules/DatabaseModule.java b/android/app/src/main/java/com/nostros/modules/DatabaseModule.java index 12dcf1a..ddac086 100644 --- a/android/app/src/main/java/com/nostros/modules/DatabaseModule.java +++ b/android/app/src/main/java/com/nostros/modules/DatabaseModule.java @@ -141,6 +141,21 @@ public class DatabaseModule { database.execSQL("ALTER TABLE nostros_relays ADD COLUMN resilient INT DEFAULT 0;"); database.execSQL("ALTER TABLE nostros_relays ADD COLUMN manual INT DEFAULT 1;"); } catch (SQLException e) { } + try { + database.execSQL("CREATE TABLE IF NOT EXISTS nostros_groups(\n" + + " id TEXT PRIMARY KEY NOT NULL, \n" + + " content TEXT NOT NULL,\n" + + " created_at INT NOT NULL,\n" + + " kind INT NOT NULL,\n" + + " pubkey TEXT NOT NULL,\n" + + " sig TEXT NOT NULL,\n" + + " tags TEXT NOT NULL,\n" + + " name TEXT NOT NULL,\n" + + " about TEXT NOT NULL,\n" + + " picture TEXT NOT NULL\n" + + " );"); + database.execSQL("CREATE INDEX nostros_groups_pubkey_index ON nostros_groups(pubkey);"); + } catch (SQLException e) { } } public void saveEvent(JSONObject data, String userPubKey, String relayUrl) throws JSONException { diff --git a/frontend/Components/GroupHeaderIcon/index.tsx b/frontend/Components/GroupHeaderIcon/index.tsx new file mode 100644 index 0000000..44f3229 --- /dev/null +++ b/frontend/Components/GroupHeaderIcon/index.tsx @@ -0,0 +1,75 @@ +import React, { useContext, useEffect, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import RBSheet from 'react-native-raw-bottom-sheet' +import { Avatar as PaperAvatar, TouchableRipple, useTheme } from 'react-native-paper' +import { getGroup, Group } from '../../Functions/DatabaseFunctions/Groups' +import { AppContext } from '../../Contexts/AppContext' +import { validImageUrl } from '../../Functions/NativeFunctions' +import FastImage from 'react-native-fast-image' + +interface GroupHeaderIconProps { + groupId: string +} + +export const GroupHeaderIcon: React.FC = ({ groupId }) => { + const { database } = useContext(AppContext) + const theme = useTheme() + const [group, setGroup] = useState() + const bottomSheetEditGroupRef = React.useRef(null) + + useEffect(() => { + if (database && groupId) { + getGroup(database, groupId).then(setGroup) + } + }, []) + + const bottomSheetStyles = React.useMemo(() => { + return { + container: { + backgroundColor: theme.colors.background, + paddingTop: 16, + paddingRight: 16, + paddingBottom: 32, + paddingLeft: 16, + borderTopRightRadius: 28, + borderTopLeftRadius: 28, + height: 'auto', + }, + } + }, []) + + return ( + + {}}> + {validImageUrl(group?.picture) ? ( + + ) : ( + + )} + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingRight: 8, + }, +}) + +export default GroupHeaderIcon + diff --git a/frontend/Components/MenuItems/index.tsx b/frontend/Components/MenuItems/index.tsx index b19ee56..0ee15a2 100644 --- a/frontend/Components/MenuItems/index.tsx +++ b/frontend/Components/MenuItems/index.tsx @@ -41,6 +41,8 @@ export const MenuItems: React.FC = () => { navigate('About') } else if (key === 'config') { navigate('Config') + } else if (key === 'contacts') { + navigate('Contacts') } } @@ -92,8 +94,8 @@ export const MenuItems: React.FC = () => { )} - {publicKey && ( - + + {publicKey && ( ( @@ -119,9 +121,15 @@ export const MenuItems: React.FC = () => { ) } /> - - )} - + )} + onPressItem('contacts', 1)} + onTouchEnd={() => setDrawerItemIndex(-1)} + /> = ({ )} - {hasLud06 ? ( + {hasLud06 && ( = ({ ]} color='#F5D112' /> - ) : ( - )} ) @@ -70,7 +69,7 @@ export const NostrosAvatar: React.FC = ({ const styles = StyleSheet.create({ iconLightning: { - marginBottom: 16, + marginBottom: -16, }, }) diff --git a/frontend/Components/NoteCard/index.tsx b/frontend/Components/NoteCard/index.tsx index ed3841c..169cb9b 100644 --- a/frontend/Components/NoteCard/index.tsx +++ b/frontend/Components/NoteCard/index.tsx @@ -473,6 +473,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignContent: 'center', + paddingBottom: 16 }, userBlockedWrapper: { flexDirection: 'row', diff --git a/frontend/Components/ProfileActions/index.tsx b/frontend/Components/ProfileActions/index.tsx index 8dfe6db..919373e 100644 --- a/frontend/Components/ProfileActions/index.tsx +++ b/frontend/Components/ProfileActions/index.tsx @@ -25,9 +25,14 @@ import ProfileShare from '../ProfileShare' interface ProfileActionsProps { user: User setUser: (user: User) => void + onActionDone?: () => void } -export const ProfileActions: React.FC = ({ user, setUser }) => { +export const ProfileActions: React.FC = ({ + user, + setUser, + onActionDone = () => {}, +}) => { const theme = useTheme() const { database } = React.useContext(AppContext) const { publicKey } = React.useContext(UserContext) @@ -174,6 +179,7 @@ export const ProfileActions: React.FC = ({ user, setUser }) icon='message-plus-outline' size={28} onPress={() => { + onActionDone() navigate('Conversation', { pubKey: user.id, title: username(user), diff --git a/frontend/Components/ProfileCard/index.tsx b/frontend/Components/ProfileCard/index.tsx index 207b91e..02f5a7b 100644 --- a/frontend/Components/ProfileCard/index.tsx +++ b/frontend/Components/ProfileCard/index.tsx @@ -79,7 +79,7 @@ export const ProfileCard: React.FC = ({ bottomSheetRef, showIm {user && ( - + bottomSheetRef.current?.close()}/> )} {showNotification && ( @@ -136,7 +136,7 @@ const styles = StyleSheet.create({ cardUser: { flexDirection: 'row', justifyContent: 'space-between', - paddingTop: 16, + padding: 16, }, card: { flexDirection: 'row', @@ -176,8 +176,7 @@ const styles = StyleSheet.create({ }, arrow: { alignContent: 'center', - justifyContent: 'center', - marginTop: -16, + justifyContent: 'center' }, }) diff --git a/frontend/Functions/DatabaseFunctions/Groups/index.ts b/frontend/Functions/DatabaseFunctions/Groups/index.ts new file mode 100644 index 0000000..2912a68 --- /dev/null +++ b/frontend/Functions/DatabaseFunctions/Groups/index.ts @@ -0,0 +1,68 @@ +import { QueryResult, QuickSQLiteConnection } from 'react-native-quick-sqlite' +import { getItems } from '..' +import { Event } from '../../../lib/nostr/Events' + +export interface Group extends Event { + name: string + about?: string + picture?: string + user_name?: string + valid_nip05: boolean +} + +const databaseToEntity: (object: any) => Group = (object = {}) => { + object.tags = object.tags ? JSON.parse(object.tags) : [] + return object as Group +} + +export const updateConversationRead: ( + conversationId: string, + db: QuickSQLiteConnection, +) => Promise = async (conversationId, db) => { + const userQuery = `UPDATE nostros_direct_messages SET read = ? WHERE conversation_id = ?` + return db.execute(userQuery, [1, conversationId]) +} + +export const updateAllRead: (db: QuickSQLiteConnection) => Promise = async ( + db, +) => { + const userQuery = `UPDATE nostros_direct_messages SET read = ?` + return db.execute(userQuery, [1]) +} + +export const getGroups: ( + db: QuickSQLiteConnection +) => Promise = async (db) => { + const groupsQuery = ` + SELECT + nostros_groups.*, nostros_users.name as user_name, nostros_users.valid_nip05 + FROM + nostros_groups + LEFT JOIN + nostros_users ON nostros_users.id = nostros_groups.pubkey + ` + const resultSet = await db.execute(groupsQuery) + const items: object[] = getItems(resultSet) + const notes: Group[] = items.map((object) => databaseToEntity(object)) + + return notes +} + +export const getGroup: ( + db: QuickSQLiteConnection, + groupId: string +) => Promise = async (db, groupId) => { + const groupsQuery = ` + SELECT + * + FROM + nostros_groups + WHERE + id = ? + ` + const resultSet = await db.execute(groupsQuery, [groupId]) + const items: object[] = getItems(resultSet) + const group: Group = databaseToEntity(items[0]) + + return group +} diff --git a/frontend/Locales/de.json b/frontend/Locales/de.json index 029a439..1e6fb8d 100644 --- a/frontend/Locales/de.json +++ b/frontend/Locales/de.json @@ -17,6 +17,7 @@ "ProfileConnect": "Profil verbinden", "ProfileLoad": "Profil laden", "Landing": "Start", + "Contacts": "Contacts", "Conversation": "Unterhaltung", "Reply": "Antworten", "Repost": "Notiz zitieren", @@ -63,6 +64,7 @@ }, "menuItems": { "relays": "Relays", + "contacts": "Contacts", "notConnected": "Nicht verbunden", "connectedRelays": "{{number}} verbunden", "following": "{{following}} folge ich", @@ -135,7 +137,7 @@ "loginMethod": "Einloggen mit", "mnemonic": "Mnemonic" }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "Öffentlichen Schlüssel kopiert", "contactAdded": "Kontakt hinzugefügt", diff --git a/frontend/Locales/en.json b/frontend/Locales/en.json index 08cdf84..f43f9d3 100644 --- a/frontend/Locales/en.json +++ b/frontend/Locales/en.json @@ -17,6 +17,7 @@ "ProfileConnect": "", "ProfileLoad": "", "Landing": "", + "Contacts": "Contacts", "Conversation": "Conversation", "Repost": "Repost note", "Reply": "Reply", @@ -69,6 +70,7 @@ "followers": "{{followers}} followers", "configuration": "Configuration", "about": "About", + "contacts": "Contacts", "reportBug": "Report a bug", "logout": "Logout" }, @@ -135,7 +137,7 @@ "mnemonic": "Mnemonic", "mnemonicInput": "Type your mnemonic words in the correct order to login." }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "Public key copied.", "contactAdded": "Profile followed.", diff --git a/frontend/Locales/es.json b/frontend/Locales/es.json index 138b610..4d529fe 100644 --- a/frontend/Locales/es.json +++ b/frontend/Locales/es.json @@ -17,6 +17,7 @@ "ProfileConnect": "", "ProfileLoad": "", "Landing": "", + "Contacts": "Contactos", "Conversation": "Conversación", "Reply": "Responder", "Repost": "Citar nota", @@ -63,6 +64,7 @@ }, "menuItems": { "relays": "Relays", + "contacts": "Contactos", "notConnected": "No conectado", "connectedRelays": "{{number}} conectados", "following": "{{following}} siguiendo", @@ -135,7 +137,7 @@ "loginMethod": "Acceder con", "mnemonic": "Mnemónico" }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "Clave pública copiada.", "contactAdded": "Perfil seguido.", diff --git a/frontend/Locales/fr.json b/frontend/Locales/fr.json index 83763e2..d8082db 100644 --- a/frontend/Locales/fr.json +++ b/frontend/Locales/fr.json @@ -17,6 +17,7 @@ "ProfileConnect": "", "ProfileLoad": "", "Landing": "", + "Contacts": "Contacts", "Conversation": "Conversation", "Reply": "Répondre", "Repost": "Citer la note", @@ -63,6 +64,7 @@ }, "menuItems": { "relays": "Relais", + "contacts": "Contacts", "notConnected": "Non connecté", "connectedRelays": "{{number}} connecté", "following": "{{following}} abonnements", @@ -141,7 +143,7 @@ "loginMethod": "Login with", "mnemonic": "Mnemonic" }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "Clé publique copiée.", "contactAdded": "Profil suivi.", diff --git a/frontend/Locales/ru.json b/frontend/Locales/ru.json index b02c615..e5dc096 100644 --- a/frontend/Locales/ru.json +++ b/frontend/Locales/ru.json @@ -17,6 +17,7 @@ "ProfileConnect": "", "ProfileLoad": "", "Landing": "", + "Contacts": "Contacts", "Conversation": "Диалог", "Reply": "Ответ", "Note": "Заметка", @@ -63,6 +64,7 @@ }, "menuItems": { "relays": "Реле", + "contacts": "Contacts", "notConnected": "Not connected", "connectedRelays": "{{number}} подключено", "following": "{{following}} following", @@ -135,7 +137,7 @@ "loginMethod": "Login with", "mnemonic": "Mnemonic" }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "Публичный ключ скопирован.", "contactAdded": "Вы подписались.", diff --git a/frontend/Locales/zhCn.json b/frontend/Locales/zhCn.json index 6c82b65..1dc0639 100644 --- a/frontend/Locales/zhCn.json +++ b/frontend/Locales/zhCn.json @@ -15,6 +15,7 @@ "homeNavigator": { "ProfileCreate": "创建用户", "ProfileConnect": "", + "Contacts": "Contacts", "ProfileLoad": "", "Landing": "", "Conversation": "会话", @@ -47,12 +48,12 @@ "isContact": "正在关注", "isNotContact": "关注", "contentWarning": "敏感内容", - "send": "发送", + "send": "发送" }, "uploadImage": { "notifications": { - "imageUploaded": "您的图片已上传\n请赞助我们的服务:", - "imageUploadErro": "上传图片出现错误", + "imageUploaded": "您的图片已上传\n请赞助我们的服务:", + "imageUploadErro": "上传图片出现错误" }, "uploadImageWarningTitle": "Important", "uploadImageWarning": "Your image will be uploaded to a public hosting service and will be visible for anyone.", @@ -61,6 +62,7 @@ "poweredBy": "由{{uri}}提供支持" }, "menuItems": { + "contacts": "Contacts", "relays": "中继", "notConnected": "未连接", "connectedRelays": "已连接 {{number}} 个中继", @@ -133,7 +135,7 @@ "loginMethod": "登入", "mnemonic": "助记词" }, - "contactsFeed": { + "contactsPage": { "notifications": { "keyCopied": "已复制公钥", "contactAdded": "已关注", diff --git a/frontend/Pages/ContactsFeed/index.tsx b/frontend/Pages/ContactsPage/index.tsx similarity index 91% rename from frontend/Pages/ContactsFeed/index.tsx rename to frontend/Pages/ContactsPage/index.tsx index a5d6410..4dd627b 100644 --- a/frontend/Pages/ContactsFeed/index.tsx +++ b/frontend/Pages/ContactsPage/index.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useState } from 'react' -import { Dimensions, NativeScrollEvent, NativeSyntheticEvent, StyleSheet, View } from 'react-native' +import { NativeScrollEvent, NativeSyntheticEvent, StyleSheet, View } from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import { AppContext } from '../../Contexts/AppContext' import { Kind } from 'nostr-tools' @@ -33,7 +33,7 @@ import ProfileData from '../../Components/ProfileData' import { handleInfinityScroll } from '../../Functions/NativeFunctions' import { queryProfile } from 'nostr-tools/nip05' -export const ContactsFeed: React.FC = () => { +export const ContactsPage: React.FC = () => { const { t } = useTranslation('common') const initialPageSize = 20 const { database, setDisplayUserDrawer } = useContext(AppContext) @@ -203,7 +203,7 @@ export const ContactsFeed: React.FC = () => { @@ -232,7 +232,7 @@ export const ContactsFeed: React.FC = () => { /> - + @@ -269,13 +269,13 @@ export const ContactsFeed: React.FC = () => { color={theme.colors.onPrimaryContainer} /> - {t('contactsFeed.emptyTitleFollowing')} + {t('contactsPage.emptyTitleFollowing')} - {t('contactsFeed.emptyDescriptionFollowing')} + {t('contactsPage.emptyDescriptionFollowing')} ) @@ -288,6 +288,7 @@ export const ContactsFeed: React.FC = () => { data={following.slice(0, pageSize)} renderItem={renderContactItem} onScroll={onScroll} + ItemSeparatorComponent={Divider} ListEmptyComponent={ListEmptyComponentFollowing} horizontal={false} /> @@ -303,10 +304,10 @@ export const ContactsFeed: React.FC = () => { color={theme.colors.onPrimaryContainer} /> - {t('contactsFeed.emptyTitleFollower')} + {t('contactsPage.emptyTitleFollower')} - {t('contactsFeed.emptyDescriptionFollower')} + {t('contactsPage.emptyDescriptionFollower')} ) @@ -345,10 +346,10 @@ export const ContactsFeed: React.FC = () => { color={theme.colors.onPrimaryContainer} /> - {t('contactsFeed.emptyTitleBlocked')} + {t('contactsPage.emptyTitleBlocked')} - {t('contactsFeed.emptyDescriptionBlocked')} + {t('contactsPage.emptyDescriptionBlocked')} ) @@ -361,6 +362,7 @@ export const ContactsFeed: React.FC = () => { data={blocked.slice(0, pageSize)} renderItem={renderBlockedItem} onScroll={onScroll} + ItemSeparatorComponent={Divider} ListEmptyComponent={ListEmptyComponentBlocked} horizontal={false} /> @@ -386,7 +388,7 @@ export const ContactsFeed: React.FC = () => { > setTabKey('following')}> - {t('contactsFeed.following', { count: following.length })} + {t('contactsPage.following', { count: following.length })} @@ -400,7 +402,7 @@ export const ContactsFeed: React.FC = () => { > setTabKey('followers')}> - {t('contactsFeed.followers', { count: followers.length })} + {t('contactsPage.followers', { count: followers.length })} @@ -414,7 +416,7 @@ export const ContactsFeed: React.FC = () => { > setTabKey('blocked')}> - {t('contactsFeed.blocked', { count: blocked.length })} + {t('contactsPage.blocked', { count: blocked.length })} @@ -422,7 +424,7 @@ export const ContactsFeed: React.FC = () => { {renderScene[tabKey]} {privateKey && ( bottomSheetAddContactRef.current?.open()} @@ -438,12 +440,12 @@ export const ContactsFeed: React.FC = () => { onClose={() => setContactInput('')} > - {t('contactsFeed.addContactTitle')} - {t('contactsFeed.addContactDescription')} + {t('contactsPage.addContactTitle')} + {t('contactsPage.addContactDescription')} { onPress={onPressAddContact} loading={isAddingContact} > - {t('contactsFeed.addContact')} + {t('contactsPage.addContact')} @@ -482,7 +484,7 @@ export const ContactsFeed: React.FC = () => { onIconPress={() => setShowNotification(undefined)} onDismiss={() => setShowNotification(undefined)} > - {t(`contactsFeed.notifications.${showNotification}`)} + {t(`contactsPage.notifications.${showNotification}`)} )} @@ -531,9 +533,7 @@ const styles = StyleSheet.create({ marginBottom: 95, }, contactRow: { - paddingLeft: 16, - paddingRight: 16, - paddingTop: 16, + padding: 16, flexDirection: 'row', justifyContent: 'space-between', }, @@ -548,6 +548,7 @@ const styles = StyleSheet.create({ alignContent: 'center', }, fab: { + bottom: 65, right: 16, position: 'absolute', }, @@ -582,4 +583,4 @@ const styles = StyleSheet.create({ }, }) -export default ContactsFeed +export default ContactsPage diff --git a/frontend/Pages/ConversationPage/index.tsx b/frontend/Pages/ConversationPage/index.tsx index eca65d6..419340e 100644 --- a/frontend/Pages/ConversationPage/index.tsx +++ b/frontend/Pages/ConversationPage/index.tsx @@ -37,6 +37,7 @@ export const ConversationPage: React.FC = ({ route }) => const { publicKey, privateKey, name, picture } = useContext(UserContext) const otherPubKey = useMemo(() => route.params.pubKey, []) const [pageSize, setPageSize] = useState(initialPageSize) + const [decryptedMessages, setDecryptedMessages] = useState>({}) const [directMessages, setDirectMessages] = useState([]) const [sendingMessages, setSendingMessages] = useState([]) const [otherUser, setOtherUser] = useState({ id: otherPubKey }) @@ -74,16 +75,22 @@ export const ConversationPage: React.FC = ({ route }) => }).then((results) => { if (results.length > 0) { setSendingMessages([]) - setDirectMessages((prev) => { - return results.map((message, index) => { - if (prev.length > index) { - return prev[index] - } else { - message.content = decrypt(privateKey, otherPubKey, message.content ?? '') - return message + setDirectMessages( + results.map((message) => { + if (message?.id) { + if (decryptedMessages[message.id]) { + message.content = decryptedMessages[message.id] + } else { + message.content = decrypt(privateKey, otherPubKey, message.content ?? '') + setDecryptedMessages((prev) => { + if (message?.id) prev[message.id] = message.content + return prev + }) + } } - }) - }) + return message + }), + ) if (subscribe) subscribeDirectMessages(results[0].created_at) } else if (subscribe) { subscribeDirectMessages() diff --git a/frontend/Pages/ConversationsFeed/index.tsx b/frontend/Pages/ConversationsFeed/index.tsx index 6881f28..302c564 100644 --- a/frontend/Pages/ConversationsFeed/index.tsx +++ b/frontend/Pages/ConversationsFeed/index.tsx @@ -349,9 +349,7 @@ const styles = StyleSheet.create({ flex: 1, }, contactRow: { - paddingLeft: 16, - paddingRight: 16, - paddingTop: 16, + padding: 16, flexDirection: 'row', justifyContent: 'space-between', width: '100%', diff --git a/frontend/Pages/FeedNavigator/index.tsx b/frontend/Pages/FeedNavigator/index.tsx index a14606d..a2fef5f 100644 --- a/frontend/Pages/FeedNavigator/index.tsx +++ b/frontend/Pages/FeedNavigator/index.tsx @@ -20,6 +20,9 @@ import { AppContext } from '../../Contexts/AppContext' import RelayCard from '../../Components/RelayCard' import { updateAllRead } from '../../Functions/DatabaseFunctions/DirectMessages' import { getUnixTime } from 'date-fns' +import ContactsPage from '../ContactsPage' +import GroupPage from '../GroupPage' +import GroupHeaderIcon from '../../Components/GroupHeaderIcon' export const HomeNavigator: React.FC = () => { const theme = useTheme() @@ -118,6 +121,9 @@ export const HomeNavigator: React.FC = () => { {['Landing'].includes(route.name) && historyKey?.includes('messages-') && ( onPressCheckAll()} /> )} + {['GroupPage'].includes(route.name) && ( + + )} ) }, @@ -131,8 +137,10 @@ export const HomeNavigator: React.FC = () => { + + diff --git a/frontend/Pages/GroupPage/index.tsx b/frontend/Pages/GroupPage/index.tsx new file mode 100644 index 0000000..1e6c22c --- /dev/null +++ b/frontend/Pages/GroupPage/index.tsx @@ -0,0 +1,347 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' +import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, StyleSheet, View } from 'react-native' +import { AppContext } from '../../Contexts/AppContext' +import { RelayPoolContext } from '../../Contexts/RelayPoolContext' +import { Event } from '../../lib/nostr/Events' +import { + DirectMessage, + getDirectMessages, + updateConversationRead, +} from '../../Functions/DatabaseFunctions/DirectMessages' +import { getUser, User } from '../../Functions/DatabaseFunctions/Users' +import { useTranslation } from 'react-i18next' +import { username, usernamePubKey, usersToTags } from '../../Functions/RelayFunctions/Users' +import { getUnixTime, formatDistance, fromUnixTime } from 'date-fns' +import TextContent from '../../Components/TextContent' +import { encrypt, decrypt } from '../../lib/nostr/Nip04' +import { Card, useTheme, TextInput, Snackbar, TouchableRipple, Text } from 'react-native-paper' +import { UserContext } from '../../Contexts/UserContext' +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons' +import { useFocusEffect } from '@react-navigation/native' +import { Kind } from 'nostr-tools' +import { handleInfinityScroll } from '../../Functions/NativeFunctions' +import NostrosAvatar from '../../Components/NostrosAvatar' +import { FlashList, ListRenderItem } from '@shopify/flash-list' +import UploadImage from '../../Components/UploadImage' + +interface GroupPageProps { + route: { params: { groupId: string } } +} + +export const GroupPage: React.FC = ({ route }) => { + const initialPageSize = 10 + const theme = useTheme() + const scrollViewRef = useRef() + const { database, setRefreshBottomBarAt, setDisplayUserDrawer } = useContext(AppContext) + const { relayPool, lastEventId } = useContext(RelayPoolContext) + const { publicKey, privateKey, name, picture } = useContext(UserContext) + const otherPubKey = useMemo(() => route.params.groupId, []) + const [pageSize, setPageSize] = useState(initialPageSize) + const [directMessages, setDirectMessages] = useState([]) + const [sendingMessages, setSendingMessages] = useState([]) + const [otherUser, setOtherUser] = useState({ id: otherPubKey }) + const [input, setInput] = useState('') + const [showNotification, setShowNotification] = useState() + const [startUpload, setStartUpload] = useState(false) + const [uploadingFile, setUploadingFile] = useState(false) + + const { t } = useTranslation('common') + + useFocusEffect( + React.useCallback(() => { + loadDirectMessages(true) + subscribeDirectMessages() + + return () => relayPool?.unsubscribe([`conversation${route.params.groupId}`]) + }, []), + ) + + useEffect(() => { + loadDirectMessages(false) + }, [lastEventId]) + + const loadDirectMessages: (subscribe: boolean) => void = (subscribe) => { + if (database && publicKey && privateKey) { + const conversationId = route.params?.conversationId + updateConversationRead(conversationId, database) + setRefreshBottomBarAt(getUnixTime(new Date())) + getUser(otherPubKey, database).then((user) => { + if (user) setOtherUser(user) + }) + getDirectMessages(database, conversationId, publicKey, otherPubKey, { + order: 'DESC', + limit: pageSize, + }).then((results) => { + if (results.length > 0) { + setSendingMessages([]) + setDirectMessages((prev) => { + return results.map((message, index) => { + if (prev.length > index) { + return prev[index] + } else { + message.content = decrypt(privateKey, otherPubKey, message.content ?? '') + return message + } + }) + }) + if (subscribe) subscribeDirectMessages(results[0].created_at) + } else if (subscribe) { + subscribeDirectMessages() + } + }) + } + } + + const subscribeDirectMessages: (lastCreateAt?: number) => void = async (lastCreateAt) => { + if (publicKey && otherPubKey) { + relayPool?.subscribe(`conversation${route.params.groupId}`, [ + { + kinds: [Kind.EncryptedDirectMessage], + authors: [publicKey], + '#p': [otherPubKey], + since: lastCreateAt ?? 0, + }, + { + kinds: [Kind.EncryptedDirectMessage], + authors: [otherPubKey], + '#p': [publicKey], + since: lastCreateAt ?? 0, + }, + ]) + } + } + + const send: () => void = () => { + if (input !== '' && otherPubKey && publicKey && privateKey) { + const event: Event = { + content: input, + created_at: getUnixTime(new Date()), + kind: Kind.EncryptedDirectMessage, + pubkey: publicKey, + tags: usersToTags([otherUser]), + } + encrypt(privateKey, otherPubKey, input) + .then((encryptedcontent) => { + relayPool + ?.sendEvent({ + ...event, + content: encryptedcontent, + }) + .catch(() => { + setShowNotification('privateMessageSendError') + }) + }) + .catch(() => { + setShowNotification('privateMessageSendError') + }) + + const directMessage = event as DirectMessage + directMessage.pending = true + setSendingMessages((prev) => [...prev, directMessage]) + setInput('') + } + } + + const renderDirectMessageItem: ListRenderItem = ({ index, item }) => { + if (!publicKey || !privateKey || !otherUser) return <> + + const displayName = + item.pubkey === publicKey ? usernamePubKey(name, publicKey) : username(otherUser) + const showAvatar = directMessages[index - 1]?.pubkey !== item.pubkey + + return ( + + {publicKey !== item.pubkey && ( + + {showAvatar && ( + setDisplayUserDrawer(otherPubKey)}> + + + )} + + )} + + + + {displayName} + + {item.pending && ( + + + + )} + + {formatDistance(fromUnixTime(item.created_at), new Date(), { addSuffix: true })} + + + + + + + {publicKey === item.pubkey && ( + + {showAvatar && ( + setDisplayUserDrawer(publicKey)}> + + + )} + + )} + + ) + } + + const onScroll: (event: NativeSyntheticEvent) => void = (event) => { + if (handleInfinityScroll(event)) { + setPageSize(pageSize + initialPageSize) + } + } + + return ( + + + + scrollViewRef.current?.scrollToEnd({ animated: true })} + left={ + ( + + )} + onPress={() => setStartUpload(true)} + /> + } + right={ + ( + + )} + onPress={send} + /> + } + /> + + {showNotification && ( + setShowNotification(undefined)} + onDismiss={() => setShowNotification(undefined)} + > + {t(`conversationPage.notifications.${showNotification}`)} + + )} + { + setInput((prev) => `${prev} ${imageUri}`) + setStartUpload(false) + }} + uploadingFile={uploadingFile} + setUploadingFile={setUploadingFile} + /> + + ) +} + +const styles = StyleSheet.create({ + scrollView: { + paddingBottom: 16, + }, + messageRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + cardContentDate: { + flexDirection: 'row', + }, + container: { + padding: 16, + justifyContent: 'space-between', + flex: 1, + }, + card: { + marginTop: 16, + flex: 6, + }, + cardContentInfo: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + cardContentPending: { + flexDirection: 'row', + alignContent: 'center', + justifyContent: 'center', + paddingRight: 5, + paddingTop: 3, + }, + pictureSpaceLeft: { + justifyContent: 'flex-end', + width: 50, + flex: 1, + }, + pictureSpaceRight: { + alignContent: 'flex-end', + justifyContent: 'flex-end', + width: 50, + flex: 1, + paddingLeft: 16, + }, + input: { + flexDirection: 'column-reverse', + marginTop: 16, + }, + snackbar: { + margin: 16, + bottom: 70, + }, +}) + +export default GroupPage diff --git a/frontend/Pages/GroupsFeed/index.tsx b/frontend/Pages/GroupsFeed/index.tsx new file mode 100644 index 0000000..835cd82 --- /dev/null +++ b/frontend/Pages/GroupsFeed/index.tsx @@ -0,0 +1,295 @@ +import React, { useContext, useEffect, useState } from 'react' +import { Dimensions, StyleSheet, View } from 'react-native' +import { useTranslation } from 'react-i18next' +import { + AnimatedFAB, + Button, + Divider, + Text, + TextInput, + TouchableRipple, + useTheme, +} from 'react-native-paper' +import RBSheet from 'react-native-raw-bottom-sheet' +import UploadImage from '../../Components/UploadImage' +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons' +import { Kind } from 'nostr-tools' +import { Event } from '../../lib/nostr/Events' +import { UserContext } from '../../Contexts/UserContext' +import { RelayPoolContext } from '../../Contexts/RelayPoolContext' +import { getUnixTime } from 'date-fns' +import { useFocusEffect } from '@react-navigation/native' +import { AppContext } from '../../Contexts/AppContext' +import { getGroups, Group } from '../../Functions/DatabaseFunctions/Groups' +import { formatId, username } from '../../Functions/RelayFunctions/Users' +import NostrosAvatar from '../../Components/NostrosAvatar' +import { navigate } from '../../lib/Navigation' +import { FlashList, ListRenderItem } from '@shopify/flash-list' + +export const GroupsFeed: React.FC = () => { + const { t } = useTranslation('common') + const theme = useTheme() + const { database } = useContext(AppContext) + const { publicKey } = useContext(UserContext) + const { relayPool, lastEventId } = useContext(RelayPoolContext) + const bottomSheetCreateRef = React.useRef(null) + const [groups, setGroups] = useState([]) + const [newGroupName, setNewGroupName] = useState() + const [newGroupDescription, setNewGroupDescription] = useState() + const [newGroupPicture, setNewGroupPicture] = useState() + const [startUpload, setStartUpload] = useState(false) + const [uploadingFile, setUploadingFile] = useState(false) + + useFocusEffect( + React.useCallback(() => { + loadGroups() + subscribeGroups() + + return () => relayPool?.unsubscribe(['groups-create', 'groups-meta', 'groups-messages']) + }, []), + ) + + useEffect(() => { + loadGroups() + }, [lastEventId]) + + const subscribeGroups: () => void = async () => { + if (publicKey) { + relayPool?.subscribe('groups-create', [ + { + kinds: [Kind.ChannelCreation], + authors: [publicKey], + }, + ]) + } + } + + const loadGroups: () => void = () => { + if (database && publicKey) { + getGroups(database).then((results) => { + if (results && results.length > 0) { + setGroups(results) + } + }) + } + } + + const createNewGroup: () => void = () => { + if (newGroupName && publicKey) { + const event: Event = { + content: JSON.stringify({ + name: newGroupName, + about: newGroupDescription, + picture: newGroupPicture, + }), + created_at: getUnixTime(new Date()), + kind: Kind.ChannelCreation, + pubkey: publicKey, + tags: [], + } + relayPool?.sendEvent(event) + bottomSheetCreateRef.current?.close() + } + } + + const renderGroupItem: ListRenderItem = ({ item }) => { + return ( + + navigate('GroupPage', { + groupId: item.id, + title: item.name || formatId(item.id), + }) + } + > + + + + + + {item.name && item.name?.length > 30 ? `${item.name?.slice(0, 30)}...` : item.name} + + + {item.about && item.about?.length > 30 + ? `${item.about?.slice(0, 30)}...` + : item.about} + + + + + + {username({ name: item.user_name, id: item.pubkey })} + {item.valid_nip05 && ( + + )} + + {formatId(item.id)} + + + + ) + } + + const bottomSheetStyles = React.useMemo(() => { + return { + container: { + backgroundColor: theme.colors.background, + paddingTop: 16, + paddingRight: 16, + paddingBottom: 32, + paddingLeft: 16, + borderTopRightRadius: 28, + borderTopLeftRadius: 28, + height: 'auto', + }, + } + }, []) + + const ListEmptyComponentBlocked = ( + + + + {t('contactsPage.emptyTitleBlocked')} + + + {t('contactsPage.emptyDescriptionBlocked')} + + + ) + + return ( + + + bottomSheetCreateRef.current?.open()} + animateFrom='right' + iconMode='static' + extended={false} + /> + + + + {t('groupsFeed.createTitle')} + + + + ( + + )} + onPress={() => setStartUpload(true)} + /> + } + /> + + { + setNewGroupPicture(imageUri) + setStartUpload(false) + }} + uploadingFile={uploadingFile} + setUploadingFile={setUploadingFile} + /> + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + blankNoButton: { + justifyContent: 'space-between', + height: 192, + marginTop: 75, + padding: 16, + }, + fab: { + right: 16, + position: 'absolute', + }, + groupDescription: { + paddingLeft: 16, + }, + input: { + marginBottom: 16, + }, + verifyIcon: { + paddingTop: 4, + paddingLeft: 5, + }, + list: { + paddingLeft: 16, + paddingRight: 16, + }, + containerAvatar: { + marginTop: 10, + }, + row: { + padding: 16, + flexDirection: 'row', + justifyContent: 'space-between', + }, + groupData: { + flexDirection: 'row', + }, + username: { + flexDirection: 'row', + }, + center: { + alignContent: 'center', + textAlign: 'center', + }, +}) + +export default GroupsFeed diff --git a/frontend/Pages/HomePage/index.tsx b/frontend/Pages/HomePage/index.tsx index ed6e668..4d33db9 100644 --- a/frontend/Pages/HomePage/index.tsx +++ b/frontend/Pages/HomePage/index.tsx @@ -1,6 +1,5 @@ import React, { useContext, useEffect, useState } from 'react' import { Badge, Button, Text, TouchableRipple, useTheme } from 'react-native-paper' -import ContactsFeed from '../ContactsFeed' import ConversationsFeed from '../ConversationsFeed' import HomeFeed from '../HomeFeed' import NotificationsFeed from '../NotificationsFeed' @@ -20,6 +19,7 @@ import { getDirectMessagesCount, getGroupedDirectMessages, } from '../../Functions/DatabaseFunctions/DirectMessages' +import GroupsFeed from '../GroupsFeed' export const HomePage: React.FC = () => { const theme = useTheme() @@ -143,38 +143,40 @@ export const HomePage: React.FC = () => { }} /> {privateKey && ( - ( - <> + <> + ( - {newdirectMessages > 0 && ( - {newdirectMessages} - )} - - ), - }} - /> + ), + }} + /> + ( + <> + + {newdirectMessages > 0 && ( + {newdirectMessages} + )} + + ), + }} + /> + )} - ( - - ), - }} - /> { + const { t } = useTranslation('common') const theme = useTheme() const bottomSheetPictureRef = React.useRef(null) const bottomSheetDirectoryRef = React.useRef(null) @@ -49,7 +52,8 @@ export const ProfileConfigPage: React.FC = () => { // State const [showNotification, setShowNotification] = useState() const [isPublishingProfile, setIsPublishingProfile] = useState() - const { t } = useTranslation('common') + const [startUpload, setStartUpload] = useState(false) + const [uploadingFile, setUploadingFile] = useState(false) useFocusEffect( React.useCallback(() => { @@ -291,6 +295,18 @@ export const ProfileConfigPage: React.FC = () => { forceTextInputFocus={false} /> } + left={ + ( + + )} + onPress={() => setStartUpload(true)} + /> + } />