Group Page

This commit is contained in:
KoalaSat 2023-02-11 20:18:22 +01:00
parent 187e13808a
commit 2a5a35031f
No known key found for this signature in database
GPG Key ID: 2F7F61C6146AB157
23 changed files with 697 additions and 174 deletions

View File

@ -4,7 +4,6 @@ about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -4,7 +4,6 @@ about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**

View File

@ -4,7 +4,6 @@ import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
@ -17,7 +16,6 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -66,6 +64,14 @@ public class Event {
saveReaction(database);
} else if (kind.equals("40")) {
saveGroup(database);
} else if (kind.equals("41")) {
updateGroup(database);
} else if (kind.equals("42")) {
saveGroupMessage(database);
} else if (kind.equals("42")) {
hideGroupMessage(database);
} else if (kind.equals("44")) {
blockUser(database);
}
} catch (JSONException e) {
e.printStackTrace();
@ -203,6 +209,61 @@ public class Event {
database.replace("nostros_notes", null, values);
}
protected void updateGroup(SQLiteDatabase database) throws JSONException {
JSONObject groupContent = new JSONObject(content);
JSONArray eTags = filterTags("e");
String groupId = eTags.getJSONArray(0).getString(1);
String query = "SELECT created_at, pubkey FROM nostros_groups WHERE id = ?";
@SuppressLint("Recycle") Cursor cursor = database.rawQuery(query, new String[] {groupId});
if (cursor.moveToFirst() && created_at > cursor.getInt(0) && pubkey.equals(cursor.getString(1))) {
ContentValues values = new ContentValues();
values.put("name", groupContent.optString("name"));
values.put("about", groupContent.optString("about"));
values.put("picture", groupContent.optString("picture"));
String whereClause = "id = ?";
String[] whereArgs = new String[] {
groupId
};
database.update("nostros_groups", values, whereClause, whereArgs);
}
}
protected void blockUser(SQLiteDatabase database) throws JSONException {
JSONArray pTags = filterTags("p");
String groupId = pTags.getJSONArray(0).getString(1);
String query = "SELECT id FROM nostros_users WHERE id = ?";
@SuppressLint("Recycle") Cursor cursor = database.rawQuery(query, new String[] {groupId});
if (cursor.getCount() == 0) {
ContentValues values = new ContentValues();
values.put("muted_groups", 1);
String whereClause = "id = ?";
String[] whereArgs = new String[] {
groupId
};
database.update("nostros_users", values, whereClause, whereArgs);
}
}
protected void hideGroupMessage(SQLiteDatabase database) throws JSONException {
JSONArray eTags = filterTags("e");
String groupId = eTags.getJSONArray(0).getString(1);
String query = "SELECT id FROM nostros_group_messages WHERE id = ?";
@SuppressLint("Recycle") Cursor cursor = database.rawQuery(query, new String[] {groupId});
if (cursor.getCount() == 0) {
ContentValues values = new ContentValues();
values.put("hidden", 1);
String whereClause = "id = ?";
String[] whereArgs = new String[] {
groupId
};
database.update("nostros_group_messages", values, whereClause, whereArgs);
}
}
protected void saveGroup(SQLiteDatabase database) throws JSONException {
JSONObject groupContent = new JSONObject(content);
@ -217,7 +278,24 @@ public class Event {
values.put("name", groupContent.optString("name"));
values.put("about", groupContent.optString("about"));
values.put("picture", groupContent.optString("picture"));
database.replace("nostros_groups", null, values);
database.insert("nostros_groups", null, values);
}
protected void saveGroupMessage(SQLiteDatabase database) throws JSONException {
JSONArray eTags = filterTags("e");
String groupId = eTags.getJSONArray(0).getString(1);
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("group_id", groupId);
database.insert("nostros_group_messages", null, values);
}
protected void saveDirectMessage(SQLiteDatabase database) throws JSONException {
@ -230,7 +308,7 @@ public class Event {
ContentValues values = new ContentValues();
values.put("id", id);
values.put("content", content.replace("'", "''"));
values.put("content", content);
values.put("created_at", created_at);
values.put("kind", kind);
values.put("pubkey", pubkey);
@ -256,7 +334,7 @@ public class Event {
ContentValues values = new ContentValues();
values.put("id", id);
values.put("content", content.replace("'", "''"));
values.put("content", content);
values.put("created_at", created_at);
values.put("kind", kind);
values.put("pubkey", pubkey);

View File

@ -142,6 +142,7 @@ public class DatabaseModule {
database.execSQL("ALTER TABLE nostros_relays ADD COLUMN manual INT DEFAULT 1;");
} catch (SQLException e) { }
try {
database.execSQL("ALTER TABLE nostros_users ADD COLUMN muted_groups INT DEFAULT 0;");
database.execSQL("CREATE TABLE IF NOT EXISTS nostros_groups(\n" +
" id TEXT PRIMARY KEY NOT NULL, \n" +
" content TEXT NOT NULL,\n" +
@ -154,7 +155,18 @@ public class DatabaseModule {
" about TEXT NOT NULL,\n" +
" picture TEXT NOT NULL\n" +
" );");
database.execSQL("CREATE INDEX nostros_groups_pubkey_index ON nostros_groups(pubkey);");
database.execSQL("CREATE TABLE IF NOT EXISTS nostros_group_messages(\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" +
" group_id TEXT NOT NULL,\n" +
" hidden INT DEFAULT 0\n" +
" );");
database.execSQL("CREATE INDEX nostros_group_messages_group_id_index ON nostros_group_messages(group_id, created_at);");
} catch (SQLException e) { }
}

View File

@ -1,27 +1,89 @@
import React, { useContext, useEffect, useState } from 'react'
import { StyleSheet, View } from 'react-native'
import { Clipboard, 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 {
Avatar as PaperAvatar,
Button,
Text,
TextInput,
TouchableRipple,
useTheme,
} from 'react-native-paper'
import { deleteGroup, getGroup, Group } from '../../Functions/DatabaseFunctions/Groups'
import { AppContext } from '../../Contexts/AppContext'
import { validImageUrl } from '../../Functions/NativeFunctions'
import FastImage from 'react-native-fast-image'
import { useTranslation } from 'react-i18next'
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'
import UploadImage from '../UploadImage'
import { UserContext } from '../../Contexts/UserContext'
import { getUnixTime } from 'date-fns'
import { Kind } from 'nostr-tools'
import { RelayPoolContext } from '../../Contexts/RelayPoolContext'
import { Event } from '../../lib/nostr/Events'
import { goBack } from '../../lib/Navigation'
interface GroupHeaderIconProps {
groupId: string
}
export const GroupHeaderIcon: React.FC<GroupHeaderIconProps> = ({ groupId }) => {
const { t } = useTranslation('common')
const { database } = useContext(AppContext)
const { publicKey } = useContext(UserContext)
const { relayPool, lastEventId } = useContext(RelayPoolContext)
const theme = useTheme()
const [group, setGroup] = useState<Group>()
const [newGroupName, setNewGroupName] = useState<string>()
const [newGroupDescription, setNewGroupDescription] = useState<string>()
const [newGroupPicture, setNewGroupPicture] = useState<string>()
const [startUpload, setStartUpload] = useState<boolean>(false)
const [uploadingFile, setUploadingFile] = useState<boolean>(false)
const bottomSheetActionsGroupRef = React.useRef<RBSheet>(null)
const bottomSheetEditGroupRef = React.useRef<RBSheet>(null)
useEffect(() => {
if (database && groupId) {
getGroup(database, groupId).then(setGroup)
getGroup(database, groupId).then((result) => {
setGroup(result)
setNewGroupName(result.name)
setNewGroupDescription(result.about)
setNewGroupPicture(result.picture)
})
}
}, [])
}, [lastEventId])
const pastePicture: () => void = () => {
Clipboard.getString().then((value) => {
setNewGroupPicture(value ?? '')
})
}
const onDeleteGroup: () => void = () => {
if (database && group?.id) {
deleteGroup(database, group?.id)
goBack()
bottomSheetActionsGroupRef.current?.close()
}
}
const updateGroup: () => void = () => {
if (newGroupName && publicKey && group?.id) {
const event: Event = {
content: JSON.stringify({
name: newGroupName,
about: newGroupDescription,
picture: newGroupPicture,
}),
created_at: getUnixTime(new Date()),
kind: Kind.ChannelMetadata,
pubkey: publicKey,
tags: [['e', group?.id, '']],
}
relayPool?.sendEvent(event)
bottomSheetEditGroupRef.current?.close()
}
}
const bottomSheetStyles = React.useMemo(() => {
return {
@ -40,8 +102,14 @@ export const GroupHeaderIcon: React.FC<GroupHeaderIconProps> = ({ groupId }) =>
return (
<View style={styles.container}>
<TouchableRipple onPress={() => {}}>
{validImageUrl(group?.picture) ? (
<TouchableRipple
onPress={() =>
group?.pubkey === publicKey
? bottomSheetEditGroupRef.current?.open()
: bottomSheetActionsGroupRef.current?.open()
}
>
{validImageUrl(group?.picture) ? (
<FastImage
style={[
{
@ -61,6 +129,89 @@ export const GroupHeaderIcon: React.FC<GroupHeaderIconProps> = ({ groupId }) =>
<PaperAvatar.Text size={35} label={group?.name ?? group?.id ?? ''} />
)}
</TouchableRipple>
<RBSheet
ref={bottomSheetActionsGroupRef}
closeOnDragDown={true}
customStyles={bottomSheetStyles}
>
<View>
<Button mode='contained' onPress={onDeleteGroup}>
{t('groupsFeed.delete')}
</Button>
</View>
</RBSheet>
<RBSheet
ref={bottomSheetEditGroupRef}
closeOnDragDown={true}
customStyles={bottomSheetStyles}
>
<View>
<Text style={styles.input} variant='titleLarge'>
{t('groupsFeed.updateTitle')}
</Text>
<TextInput
style={styles.input}
mode='outlined'
label={t('groupsFeed.newGroupName') ?? ''}
onChangeText={setNewGroupName}
value={newGroupName}
/>
<TextInput
style={styles.input}
multiline
mode='outlined'
label={t('groupsFeed.newGroupDescription') ?? ''}
onChangeText={setNewGroupDescription}
value={newGroupDescription}
/>
<TextInput
style={styles.input}
mode='outlined'
label={t('groupsFeed.newGroupPicture') ?? ''}
onChangeText={setNewGroupPicture}
value={newGroupPicture}
left={
<TextInput.Icon
icon={() => (
<MaterialCommunityIcons
name='image-outline'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
onPress={() => setStartUpload(true)}
/>
}
right={
<TextInput.Icon
icon='content-paste'
onPress={pastePicture}
forceTextInputFocus={false}
/>
}
/>
<Button
mode='contained'
disabled={!newGroupName}
onPress={() => updateGroup()}
style={styles.input}
>
{t('groupsFeed.groupUpdate')}
</Button>
<Button mode='outlined' onPress={onDeleteGroup}>
{t('groupsFeed.delete')}
</Button>
<UploadImage
startUpload={startUpload}
setImageUri={(imageUri) => {
setNewGroupPicture(imageUri)
setStartUpload(false)
}}
uploadingFile={uploadingFile}
setUploadingFile={setUploadingFile}
/>
</View>
</RBSheet>
</View>
)
}
@ -69,7 +220,9 @@ const styles = StyleSheet.create({
container: {
paddingRight: 8,
},
input: {
marginBottom: 16,
},
})
export default GroupHeaderIcon

View File

@ -473,7 +473,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignContent: 'center',
paddingBottom: 16
paddingBottom: 16,
},
userBlockedWrapper: {
flexDirection: 'row',

View File

@ -5,6 +5,7 @@ import { IconButton, List, Snackbar, Text, useTheme } from 'react-native-paper'
import { AppContext } from '../../Contexts/AppContext'
import { RelayPoolContext } from '../../Contexts/RelayPoolContext'
import { UserContext } from '../../Contexts/UserContext'
import { Event } from '../../lib/nostr/Events'
import {
addUser,
getUser,
@ -21,6 +22,10 @@ import { getUserRelays, NoteRelay } from '../../Functions/DatabaseFunctions/Note
import { relayToColor } from '../../Functions/NativeFunctions'
import { Relay } from '../../Functions/DatabaseFunctions/Relays'
import ProfileShare from '../ProfileShare'
import { deleteGroupMessages } from '../../Functions/DatabaseFunctions/Groups'
import { getUnixTime } from 'date-fns'
import { Kind } from 'nostr-tools'
import { ScrollView } from 'react-native-gesture-handler'
interface ProfileActionsProps {
user: User
@ -78,12 +83,25 @@ export const ProfileActions: React.FC<ProfileActionsProps> = ({
}
const onChangeBlockUser: () => void = () => {
if (database) {
if (database && publicKey) {
addUser(user.id, database).then(() => {
updateUserBlock(user.id, database, !isBlocked).then(() => {
loadUser()
setShowNotificationRelay(isBlocked ? 'userUnblocked' : 'userBlocked')
})
if (!isBlocked) {
const event: Event = {
content: '',
created_at: getUnixTime(new Date()),
kind: Kind.ChannelMuteUser,
pubkey: publicKey,
tags: [['p', user.id]],
}
relayPool?.sendEvent(event)
deleteGroupMessages(database, user.id).then(() => {
onActionDone()
})
}
})
}
}
@ -231,7 +249,7 @@ export const ProfileActions: React.FC<ProfileActionsProps> = ({
<View>
<Text variant='titleLarge'>{t('profileCard.relaysTitle')}</Text>
<Text variant='bodyMedium'>
{t('profileCard.relaysDescription', { username: username(user) })}
{t('profilePage.relaysDescription', { username: username(user) })}
</Text>
<List.Item
title={t('relaysPage.relayName')}
@ -241,11 +259,13 @@ export const ProfileActions: React.FC<ProfileActionsProps> = ({
</>
)}
/>
<FlatList
showsVerticalScrollIndicator={false}
data={userRelays}
renderItem={renderRelayItem}
/>
<ScrollView>
<FlatList
showsVerticalScrollIndicator={false}
data={userRelays}
renderItem={renderRelayItem}
/>
</ScrollView>
</View>
{showNotificationRelay && (
<Snackbar

View File

@ -79,7 +79,11 @@ export const ProfileCard: React.FC<ProfileCardProps> = ({ bottomSheetRef, showIm
<Divider />
{user && (
<View style={styles.profileActions}>
<ProfileActions user={user} setUser={setUser} onActionDone={() => bottomSheetRef.current?.close()}/>
<ProfileActions
user={user}
setUser={setUser}
onActionDone={() => bottomSheetRef.current?.close()}
/>
</View>
)}
{showNotification && (
@ -176,7 +180,7 @@ const styles = StyleSheet.create({
},
arrow: {
alignContent: 'center',
justifyContent: 'center'
justifyContent: 'center',
},
})

View File

@ -104,10 +104,11 @@ export const RelayCard: React.FC<RelayCardProps> = ({ url, bottomSheetRef }) =>
Accept: 'application/nostr+json',
}
axios
.get('http://' + uri, {
.get('http://' + uri.replace('wss://', '').replace('ws://', ''), {
headers,
})
.then((response) => {
console.log(response)
setRelayInfo(response.data)
})
.catch((e) => {

View File

@ -6,6 +6,7 @@ export interface DirectMessage extends Event {
conversation_id: string
read: boolean
pending: boolean
valid_nip05: boolean
}
const databaseToEntity: (object: any) => DirectMessage = (object = {}) => {

View File

@ -10,11 +10,23 @@ export interface Group extends Event {
valid_nip05: boolean
}
const databaseToEntity: (object: any) => Group = (object = {}) => {
export interface GroupMessage extends Event {
pending: boolean
name: string
picture?: string
valid_nip05?: boolean
}
const databaseToGroup: (object: any) => Group = (object = {}) => {
object.tags = object.tags ? JSON.parse(object.tags) : []
return object as Group
}
const databaseToGroupMessage: (object: any) => GroupMessage = (object = {}) => {
object.tags = object.tags ? JSON.parse(object.tags) : []
return object as GroupMessage
}
export const updateConversationRead: (
conversationId: string,
db: QuickSQLiteConnection,
@ -30,9 +42,7 @@ export const updateAllRead: (db: QuickSQLiteConnection) => Promise<QueryResult |
return db.execute(userQuery, [1])
}
export const getGroups: (
db: QuickSQLiteConnection
) => Promise<Group[]> = async (db) => {
export const getGroups: (db: QuickSQLiteConnection) => Promise<Group[]> = async (db) => {
const groupsQuery = `
SELECT
nostros_groups.*, nostros_users.name as user_name, nostros_users.valid_nip05
@ -43,15 +53,15 @@ export const getGroups: (
`
const resultSet = await db.execute(groupsQuery)
const items: object[] = getItems(resultSet)
const notes: Group[] = items.map((object) => databaseToEntity(object))
const notes: Group[] = items.map((object) => databaseToGroup(object))
return notes
}
export const getGroup: (
db: QuickSQLiteConnection,
groupId: string
) => Promise<Group> = async (db, groupId) => {
export const getGroup: (db: QuickSQLiteConnection, groupId: string) => Promise<Group> = async (
db,
groupId,
) => {
const groupsQuery = `
SELECT
*
@ -62,7 +72,65 @@ export const getGroup: (
`
const resultSet = await db.execute(groupsQuery, [groupId])
const items: object[] = getItems(resultSet)
const group: Group = databaseToEntity(items[0])
const group: Group = databaseToGroup(items[0])
return group
}
export const getGroupMessages: (
db: QuickSQLiteConnection,
groupId: string,
options: {
order?: 'DESC' | 'ASC'
limit?: number
},
) => Promise<GroupMessage[]> = async (db, groupId, { order = 'DESC', limit }) => {
let notesQuery = `
SELECT
nostros_group_messages.*, nostros_users.name, nostros_users.picture, nostros_users.valid_nip05
FROM
nostros_group_messages
LEFT JOIN
nostros_users ON nostros_users.id = nostros_group_messages.pubkey
WHERE group_id = "${groupId}"
AND nostros_users.muted_groups < 1
ORDER BY created_at ${order}
`
if (limit) {
notesQuery += `LIMIT ${limit}`
}
const resultSet = await db.execute(notesQuery)
const items: object[] = getItems(resultSet)
const messages: GroupMessage[] = items.map((object) => databaseToGroupMessage(object))
return messages
}
export const deleteGroupMessages: (
db: QuickSQLiteConnection,
pubkey: string,
) => Promise<QueryResult> = async (db, pubkey) => {
const deleteQuery = `
DELETE FROM nostros_group_messages
WHERE pubkey = ?
`
return db.execute(deleteQuery, [pubkey])
}
export const deleteGroup: (
db: QuickSQLiteConnection,
groupId: string,
) => Promise<QueryResult> = async (db, groupId) => {
const deleteMessagesQuery = `
DELETE FROM nostros_group_messages
WHERE group_id = ?
`
await db.execute(deleteMessagesQuery, [groupId])
const deleteQuery = `
DELETE FROM nostros_groups
WHERE id = ?
`
return db.execute(deleteQuery, [groupId])
}

View File

@ -74,7 +74,7 @@ export const validBlueBirdUrl: (url: string | undefined) => boolean = (url) => {
export const validNip21: (string: string | undefined) => boolean = (string) => {
if (string) {
const regexp = /^(nostr:)?(npub1|nprofile1|nevent1|nrelay1)\S*$/
const regexp = /^(nostr:)?(npub1|nprofile1|nevent1|nrelay1|note1)\S*$/
return regexp.test(string)
} else {
return false

View File

@ -62,6 +62,9 @@
"cancel": "Abbrechen",
"poweredBy": "poweredBy {{uri}}"
},
"groupPage": {
"typeMessage": "Nachricht schreiben"
},
"menuItems": {
"relays": "Relays",
"contacts": "Contacts",

View File

@ -310,6 +310,22 @@
"isFollower": "follows you",
"relaysDescription": "These are {{username}}'s relays, activate the ones you want to be connected."
},
"groupPage": {
"typeMessage": "Type message"
},
"groupsFeed": {
"delete": "Delete",
"updateTitle": "Update group",
"newGroupName": "Name",
"newGroupDescription": "Description",
"newGroupPicture": "Picture",
"groupUpdate": "Update",
"newGroupCreate": "Create",
"createTitle": "Create group",
"groupId": "Group id",
"addTitle": "Add existing group",
"add": "Add"
},
"homePage": {
"clipboardTitle": "Nostr key detected",
"goToEvent": "Open",

View File

@ -62,6 +62,9 @@
"cancel": "Cancelar",
"poweredBy": "Servido por {{uri}}"
},
"groupPage": {
"typeMessage": "Escribir mensaje"
},
"menuItems": {
"relays": "Relays",
"contacts": "Contactos",

View File

@ -62,6 +62,9 @@
"cancel": "Cancel",
"poweredBy": "Powered by {{uri}}"
},
"groupPage": {
"typeMessage": "Écrire un message"
},
"menuItems": {
"relays": "Relais",
"contacts": "Contacts",

View File

@ -62,6 +62,9 @@
"cancel": "Отменить",
"poweredBy": "Powered by {{uri}}"
},
"groupPage": {
"typeMessage": "Напишите сообщение"
},
"menuItems": {
"relays": "Реле",
"contacts": "Contacts",

View File

@ -61,6 +61,9 @@
"cancel": "取消",
"poweredBy": "由{{uri}}提供支持"
},
"groupPage": {
"typeMessage": "输入信息"
},
"menuItems": {
"contacts": "Contacts",
"relays": "中继",
@ -143,7 +146,7 @@
"contactRemoved": "已取消关注",
"contactUnblocked": "用户已屏蔽"
},
"emptyTitleBlocked": "您还没有屏蔽任何人",
"emptyTitleBlocked": "您还没有屏蔽任何人",
"emptyDescriptionBlocked": "您可以随时在用户详情页屏蔽用户",
"emptyTitleFollowing": "您还没有关注任何用户",
"emptyDescriptionFollowing": "关注一些用户以查看内容",

View File

@ -34,7 +34,7 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
const scrollViewRef = useRef<ScrollView>()
const { database, setRefreshBottomBarAt, setDisplayUserDrawer } = useContext(AppContext)
const { relayPool, lastEventId } = useContext(RelayPoolContext)
const { publicKey, privateKey, name, picture } = useContext(UserContext)
const { publicKey, privateKey, name, picture, validNip05 } = useContext(UserContext)
const otherPubKey = useMemo(() => route.params.pubKey, [])
const [pageSize, setPageSize] = useState<number>(initialPageSize)
const [decryptedMessages, setDecryptedMessages] = useState<Record<string, string>>({})
@ -91,7 +91,8 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
return message
}),
)
if (subscribe) subscribeDirectMessages(results[0].created_at)
const lastCreateAt = pageSize <= results.length ? results[0].created_at : 0
if (subscribe) subscribeDirectMessages(lastCreateAt)
} else if (subscribe) {
subscribeDirectMessages()
}
@ -144,6 +145,7 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
const directMessage = event as DirectMessage
directMessage.pending = true
directMessage.valid_nip05 = validNip05
setSendingMessages((prev) => [...prev, directMessage])
setInput('')
}
@ -155,6 +157,7 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
const displayName =
item.pubkey === publicKey ? usernamePubKey(name, publicKey) : username(otherUser)
const showAvatar = directMessages[index - 1]?.pubkey !== item.pubkey
const nip05 = item.pubkey === publicKey ? validNip05 : otherUser.valid_nip05
return (
<View style={styles.messageRow}>
@ -184,7 +187,18 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
>
<Card.Content>
<View style={styles.cardContentInfo}>
<Text>{displayName}</Text>
<View style={styles.cardContentName}>
<Text>{displayName}</Text>
{nip05 ? (
<MaterialCommunityIcons
name='check-decagram-outline'
color={theme.colors.onPrimaryContainer}
style={styles.verifyIcon}
/>
) : (
<></>
)}
</View>
<View style={styles.cardContentDate}>
{item.pending && (
<View style={styles.cardContentPending}>
@ -310,7 +324,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
container: {
padding: 16,
paddingLeft: 16,
paddingBottom: 16,
paddingRight: 16,
justifyContent: 'space-between',
flex: 1,
},
@ -349,6 +365,13 @@ const styles = StyleSheet.create({
margin: 16,
bottom: 70,
},
verifyIcon: {
paddingTop: 4,
paddingLeft: 5,
},
cardContentName: {
flexDirection: 'row',
},
})
export default ConversationPage

View File

@ -121,8 +121,8 @@ export const HomeNavigator: React.FC = () => {
{['Landing'].includes(route.name) && historyKey?.includes('messages-') && (
<Appbar.Action icon='check-all' isLeading onPress={() => onPressCheckAll()} />
)}
{['GroupPage'].includes(route.name) && (
<GroupHeaderIcon groupId={route.params?.groupId}/>
{['Group'].includes(route.name) && (
<GroupHeaderIcon groupId={route.params?.groupId} />
)}
</Appbar.Header>
)
@ -137,7 +137,7 @@ export const HomeNavigator: React.FC = () => {
<Stack.Screen name='Repost' component={SendPage} />
<Stack.Screen name='Reply' component={SendPage} />
<Stack.Screen name='Conversation' component={ConversationPage} />
<Stack.Screen name='GroupPage' component={GroupPage} />
<Stack.Screen name='Group' component={GroupPage} />
</Stack.Group>
<Stack.Group>
<Stack.Screen name='Contacts' component={ContactsPage} />

View File

@ -3,18 +3,11 @@ import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, StyleSheet, View }
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 { username, usernamePubKey } 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 { Card, useTheme, TextInput, 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'
@ -23,6 +16,8 @@ import { handleInfinityScroll } from '../../Functions/NativeFunctions'
import NostrosAvatar from '../../Components/NostrosAvatar'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import UploadImage from '../../Components/UploadImage'
import { getGroupMessages, GroupMessage } from '../../Functions/DatabaseFunctions/Groups'
import { RelayFilters } from '../../lib/nostr/RelayPool/intex'
interface GroupPageProps {
route: { params: { groupId: string } }
@ -32,16 +27,14 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
const initialPageSize = 10
const theme = useTheme()
const scrollViewRef = useRef<ScrollView>()
const { database, setRefreshBottomBarAt, setDisplayUserDrawer } = useContext(AppContext)
const { database, setDisplayUserDrawer } = useContext(AppContext)
const { relayPool, lastEventId } = useContext(RelayPoolContext)
const { publicKey, privateKey, name, picture } = useContext(UserContext)
const { publicKey, privateKey, name, picture, validNip05 } = useContext(UserContext)
const otherPubKey = useMemo(() => route.params.groupId, [])
const [pageSize, setPageSize] = useState<number>(initialPageSize)
const [directMessages, setDirectMessages] = useState<DirectMessage[]>([])
const [sendingMessages, setSendingMessages] = useState<DirectMessage[]>([])
const [otherUser, setOtherUser] = useState<User>({ id: otherPubKey })
const [groupMessages, setGroupMessages] = useState<GroupMessage[]>([])
const [sendingMessages, setSendingMessages] = useState<GroupMessage[]>([])
const [input, setInput] = useState<string>('')
const [showNotification, setShowNotification] = useState<string>()
const [startUpload, setStartUpload] = useState<boolean>(false)
const [uploadingFile, setUploadingFile] = useState<boolean>(false)
@ -49,105 +42,96 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
useFocusEffect(
React.useCallback(() => {
loadDirectMessages(true)
subscribeDirectMessages()
loadGroupMessages(true)
return () => relayPool?.unsubscribe([`conversation${route.params.groupId}`])
return () => relayPool?.unsubscribe([`group${route.params.groupId}`])
}, []),
)
useEffect(() => {
loadDirectMessages(false)
loadGroupMessages(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, {
const loadGroupMessages: (subscribe: boolean) => void = (subscribe) => {
if (database && publicKey && privateKey && route.params.groupId) {
getGroupMessages(database, route.params.groupId, {
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)
setGroupMessages(results)
const pubKeys = results
.map((message) => message.pubkey)
.filter((key, index, array) => array.indexOf(key) === index)
const lastCreateAt = pageSize <= results.length ? results[0].created_at : 0
if (subscribe) subscribeGroupMessages(lastCreateAt, pubKeys)
} else if (subscribe) {
subscribeDirectMessages()
subscribeGroupMessages()
}
})
}
}
const subscribeDirectMessages: (lastCreateAt?: number) => void = async (lastCreateAt) => {
if (publicKey && otherPubKey) {
relayPool?.subscribe(`conversation${route.params.groupId}`, [
const subscribeGroupMessages: (lastCreateAt?: number, pubKeys?: string[]) => void = async (
lastCreateAt,
pubKeys,
) => {
if (publicKey && otherPubKey && route.params.groupId) {
const filters: RelayFilters[] = [
{
kinds: [Kind.EncryptedDirectMessage],
authors: [publicKey],
'#p': [otherPubKey],
since: lastCreateAt ?? 0,
kinds: [Kind.ChannelCreation],
ids: [route.params.groupId],
},
{
kinds: [Kind.EncryptedDirectMessage],
authors: [otherPubKey],
'#p': [publicKey],
since: lastCreateAt ?? 0,
kinds: [Kind.ChannelMetadata],
'#e': [route.params.groupId],
},
])
{
kinds: [Kind.ChannelMessage],
'#e': [route.params.groupId],
since: lastCreateAt ?? 0,
limit: pageSize,
},
]
if (pubKeys && pubKeys.length > 0) {
filters.push({
kinds: [Kind.Metadata],
authors: pubKeys,
})
}
relayPool?.subscribe(`group${route.params.groupId}`, filters)
}
}
const send: () => void = () => {
if (input !== '' && otherPubKey && publicKey && privateKey) {
if (input !== '' && otherPubKey && publicKey && privateKey && route.params.groupId) {
const event: Event = {
content: input,
created_at: getUnixTime(new Date()),
kind: Kind.EncryptedDirectMessage,
kind: Kind.ChannelMessage,
pubkey: publicKey,
tags: usersToTags([otherUser]),
tags: [['e', route.params.groupId, '']],
}
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])
relayPool?.sendEvent(event)
const groupMessage = event as GroupMessage
groupMessage.pending = true
groupMessage.valid_nip05 = validNip05
setSendingMessages((prev) => [...prev, groupMessage])
setInput('')
}
}
const renderDirectMessageItem: ListRenderItem<DirectMessage> = ({ index, item }) => {
if (!publicKey || !privateKey || !otherUser) return <></>
const renderGroupMessageItem: ListRenderItem<GroupMessage> = ({ index, item }) => {
if (!publicKey) return <></>
const displayName =
item.pubkey === publicKey ? usernamePubKey(name, publicKey) : username(otherUser)
const showAvatar = directMessages[index - 1]?.pubkey !== item.pubkey
item.pubkey === publicKey
? usernamePubKey(name, publicKey)
: username({ name: item.name, id: item.pubkey })
const showAvatar = groupMessages[index - 1]?.pubkey !== item.pubkey
return (
<View style={styles.messageRow}>
@ -156,9 +140,9 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
{showAvatar && (
<TouchableRipple onPress={() => setDisplayUserDrawer(otherPubKey)}>
<NostrosAvatar
name={otherUser.name}
name={displayName}
pubKey={otherPubKey}
src={otherUser.picture}
src={item.picture}
size={40}
/>
</TouchableRipple>
@ -177,7 +161,18 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
>
<Card.Content>
<View style={styles.cardContentInfo}>
<Text>{displayName}</Text>
<View style={styles.cardContentName}>
<Text>{displayName}</Text>
{item.valid_nip05 ? (
<MaterialCommunityIcons
name='check-decagram-outline'
color={theme.colors.onPrimaryContainer}
style={styles.verifyIcon}
/>
) : (
<></>
)}
</View>
<View style={styles.cardContentDate}>
{item.pending && (
<View style={styles.cardContentPending}>
@ -193,7 +188,7 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
</Text>
</View>
</View>
<TextContent content={item.content} />
<TextContent content={item.content} event={item} />
</Card.Content>
</Card>
{publicKey === item.pubkey && (
@ -219,8 +214,8 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
<View style={styles.container}>
<FlashList
inverted
data={[...sendingMessages, ...directMessages]}
renderItem={renderDirectMessageItem}
data={[...sendingMessages, ...groupMessages]}
renderItem={renderGroupMessageItem}
horizontal={false}
ref={scrollViewRef}
estimatedItemSize={100}
@ -237,7 +232,7 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
<TextInput
mode='outlined'
multiline
label={t('conversationPage.typeMessage') ?? ''}
label={t('groupPage.typeMessage') ?? ''}
value={input}
onChangeText={setInput}
onFocus={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
@ -267,17 +262,6 @@ export const GroupPage: React.FC<GroupPageProps> = ({ route }) => {
}
/>
</View>
{showNotification && (
<Snackbar
style={styles.snackbar}
visible={showNotification !== undefined}
duration={Snackbar.DURATION_SHORT}
onIconPress={() => setShowNotification(undefined)}
onDismiss={() => setShowNotification(undefined)}
>
{t(`conversationPage.notifications.${showNotification}`)}
</Snackbar>
)}
<UploadImage
startUpload={startUpload}
setImageUri={(imageUri) => {
@ -303,7 +287,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
container: {
padding: 16,
paddingLeft: 16,
paddingBottom: 16,
paddingRight: 16,
justifyContent: 'space-between',
flex: 1,
},
@ -342,6 +328,13 @@ const styles = StyleSheet.create({
margin: 16,
bottom: 70,
},
verifyIcon: {
paddingTop: 4,
paddingLeft: 5,
},
cardContentName: {
flexDirection: 'row',
},
})
export default GroupPage

View File

@ -1,10 +1,12 @@
import React, { useContext, useEffect, useState } from 'react'
import { Dimensions, StyleSheet, View } from 'react-native'
import { Clipboard, Dimensions, FlatList, StyleSheet, View } from 'react-native'
import { useTranslation } from 'react-i18next'
import {
AnimatedFAB,
Button,
Divider,
List,
Snackbar,
Text,
TextInput,
TouchableRipple,
@ -25,25 +27,31 @@ import { formatId, username } from '../../Functions/RelayFunctions/Users'
import NostrosAvatar from '../../Components/NostrosAvatar'
import { navigate } from '../../lib/Navigation'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { RelayFilters } from '../../lib/nostr/RelayPool/intex'
import { validNip21 } from '../../Functions/NativeFunctions'
import { getNip19Key } from '../../lib/nostr/Nip19'
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 { relayPool, lastEventId, lastConfirmationtId } = useContext(RelayPoolContext)
const bottomSheetSearchRef = React.useRef<RBSheet>(null)
const bottomSheetCreateRef = React.useRef<RBSheet>(null)
const bottomSheetFabActionRef = React.useRef<RBSheet>(null)
const [groups, setGroups] = useState<Group[]>([])
const [searchGroup, setSearchGroup] = useState<string>()
const [newGroupName, setNewGroupName] = useState<string>()
const [newGroupDescription, setNewGroupDescription] = useState<string>()
const [newGroupPicture, setNewGroupPicture] = useState<string>()
const [startUpload, setStartUpload] = useState<boolean>(false)
const [uploadingFile, setUploadingFile] = useState<boolean>(false)
const [showNotification, setShowNotification] = useState<string>()
useFocusEffect(
React.useCallback(() => {
loadGroups()
subscribeGroups()
return () => relayPool?.unsubscribe(['groups-create', 'groups-meta', 'groups-messages'])
}, []),
@ -51,27 +59,56 @@ export const GroupsFeed: React.FC = () => {
useEffect(() => {
loadGroups()
}, [lastEventId])
}, [lastEventId, lastConfirmationtId])
const subscribeGroups: () => void = async () => {
if (publicKey) {
relayPool?.subscribe('groups-create', [
{
kinds: [Kind.ChannelCreation],
authors: [publicKey],
},
])
const pastePicture: () => void = () => {
Clipboard.getString().then((value) => {
setNewGroupPicture(value ?? '')
})
}
const loadGroups: (newGroupId?: string) => void = (newGroupId) => {
if (database && publicKey) {
getGroups(database).then((results) => {
const filters: RelayFilters[] = [
{
kinds: [Kind.ChannelCreation],
authors: [publicKey],
},
]
if (results && results.length > 0) {
setGroups(results)
filters.push({
kinds: [Kind.Metadata],
ids: [...results.map((group) => group.pubkey)],
})
filters.push({
kinds: [Kind.ChannelMetadata],
ids: [...results.map((group) => group.id ?? ''), publicKey, newGroupId ?? ''],
})
if (newGroupId) {
filters.push({
kinds: [Kind.ChannelCreation],
ids: [newGroupId],
})
}
}
relayPool?.subscribe('groups-create', filters)
})
}
}
const loadGroups: () => void = () => {
if (database && publicKey) {
getGroups(database).then((results) => {
if (results && results.length > 0) {
setGroups(results)
}
})
const addGroup: () => void = () => {
if (!searchGroup) return
if (validNip21(searchGroup)) {
const key = getNip19Key(searchGroup)
if (key) loadGroups(key)
} else {
loadGroups(searchGroup)
}
setSearchGroup(undefined)
bottomSheetSearchRef.current?.close()
bottomSheetFabActionRef.current?.close()
}
const createNewGroup: () => void = () => {
@ -89,6 +126,7 @@ export const GroupsFeed: React.FC = () => {
}
relayPool?.sendEvent(event)
bottomSheetCreateRef.current?.close()
bottomSheetFabActionRef.current?.close()
}
}
@ -96,7 +134,7 @@ export const GroupsFeed: React.FC = () => {
return (
<TouchableRipple
onPress={() =>
navigate('GroupPage', {
navigate('Group', {
groupId: item.id,
title: item.name || formatId(item.id),
})
@ -158,14 +196,53 @@ export const GroupsFeed: React.FC = () => {
color={theme.colors.onPrimaryContainer}
/>
<Text variant='headlineSmall' style={styles.center}>
{t('contactsPage.emptyTitleBlocked')}
{t('groupsFeed.emptyTitleBlocked')}
</Text>
<Text variant='bodyMedium' style={styles.center}>
{t('contactsPage.emptyDescriptionBlocked')}
{t('groupsFeed.emptyDescriptionBlocked')}
</Text>
</View>
)
const fabOptions = React.useMemo(() => {
return [
{
key: 1,
title: t('groupsFeed.createTitle'),
left: () => (
<List.Icon
icon={() => (
<MaterialCommunityIcons
name='account-multiple-plus-outline'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
/>
),
onPress: async () => bottomSheetCreateRef.current?.open(),
},
{
key: 2,
title: t('groupsFeed.add'),
left: () => (
<List.Icon
icon={() => (
<MaterialCommunityIcons
name='plus'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
/>
),
onPress: async () => bottomSheetSearchRef.current?.open(),
disabled: false,
style: {},
},
]
}, [])
return (
<View style={styles.container}>
<FlashList
@ -181,7 +258,7 @@ export const GroupsFeed: React.FC = () => {
style={[styles.fab, { top: Dimensions.get('window').height - 216 }]}
icon='plus'
label='Label'
onPress={() => bottomSheetCreateRef.current?.open()}
onPress={() => bottomSheetFabActionRef.current?.open()}
animateFrom='right'
iconMode='static'
extended={false}
@ -208,7 +285,6 @@ export const GroupsFeed: React.FC = () => {
/>
<TextInput
style={styles.input}
multiline
mode='outlined'
label={t('groupsFeed.newGroupPicture') ?? ''}
onChangeText={setNewGroupPicture}
@ -225,6 +301,13 @@ export const GroupsFeed: React.FC = () => {
onPress={() => setStartUpload(true)}
/>
}
right={
<TextInput.Icon
icon='content-paste'
onPress={pastePicture}
forceTextInputFocus={false}
/>
}
/>
<Button mode='contained' disabled={!newGroupName} onPress={createNewGroup}>
{t('groupsFeed.newGroupCreate')}
@ -240,6 +323,57 @@ export const GroupsFeed: React.FC = () => {
/>
</View>
</RBSheet>
<RBSheet ref={bottomSheetSearchRef} closeOnDragDown={true} customStyles={bottomSheetStyles}>
<View>
<Text style={styles.input} variant='titleLarge'>
{t('groupsFeed.addTitle')}
</Text>
<TextInput
style={styles.input}
mode='outlined'
label={t('groupsFeed.groupId') ?? ''}
onChangeText={setSearchGroup}
value={searchGroup}
/>
<Button mode='contained' disabled={!searchGroup} onPress={addGroup}>
{t('groupsFeed.add')}
</Button>
</View>
</RBSheet>
<RBSheet
ref={bottomSheetFabActionRef}
closeOnDragDown={true}
customStyles={bottomSheetStyles}
>
<FlatList
data={fabOptions}
renderItem={({ item }) => {
return (
<List.Item
key={item.key}
title={item.title}
onPress={item.onPress}
left={item.left}
disabled={item.disabled}
titleStyle={item.style}
/>
)
}}
ItemSeparatorComponent={Divider}
horizontal={false}
/>
</RBSheet>
{showNotification && (
<Snackbar
style={styles.snackbar}
visible={showNotification !== undefined}
duration={Snackbar.DURATION_SHORT}
onIconPress={() => setShowNotification(undefined)}
onDismiss={() => setShowNotification(undefined)}
>
{t(`groupsFeed.notifications.${showNotification}`)}
</Snackbar>
)}
</View>
)
}
@ -272,6 +406,10 @@ const styles = StyleSheet.create({
paddingLeft: 16,
paddingRight: 16,
},
snackbar: {
marginLeft: 16,
bottom: 16,
},
containerAvatar: {
marginTop: 10,
},

View File

@ -231,7 +231,7 @@ const styles = StyleSheet.create({
profileData: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingBottom: 16
paddingBottom: 16,
},
})