Groups list (#308)

This commit is contained in:
KoalaSat 2023-02-11 14:20:11 +00:00 committed by GitHub
commit 187e13808a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 979 additions and 92 deletions

View File

@ -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<String> identifiers = new ArrayList<>();

View File

@ -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 {

View File

@ -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<GroupHeaderIconProps> = ({ groupId }) => {
const { database } = useContext(AppContext)
const theme = useTheme()
const [group, setGroup] = useState<Group>()
const bottomSheetEditGroupRef = React.useRef<RBSheet>(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 (
<View style={styles.container}>
<TouchableRipple onPress={() => {}}>
{validImageUrl(group?.picture) ? (
<FastImage
style={[
{
backgroundColor: theme.colors.backdrop,
borderRadius: 33,
width: 35,
height: 35,
},
]}
source={{
uri: group?.picture,
priority: FastImage.priority.normal,
}}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<PaperAvatar.Text size={35} label={group?.name ?? group?.id ?? ''} />
)}
</TouchableRipple>
</View>
)
}
const styles = StyleSheet.create({
container: {
paddingRight: 8,
},
})
export default GroupHeaderIcon

View File

@ -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 = () => {
</Card.Content>
</Card>
)}
{publicKey && (
<Drawer.Section showDivider={false}>
<Drawer.Section>
{publicKey && (
<Drawer.Item
label={t('menuItems.relays')}
icon={() => (
@ -119,9 +121,15 @@ export const MenuItems: React.FC = () => {
)
}
/>
</Drawer.Section>
)}
<Drawer.Section>
)}
<Drawer.Item
label={t('menuItems.contacts')}
icon='contacts-outline'
key='contacts'
active={drawerItemIndex === 1}
onPress={() => onPressItem('contacts', 1)}
onTouchEnd={() => setDrawerItemIndex(-1)}
/>
<Drawer.Item
label={t('menuItems.configuration')}
icon='cog'

View File

@ -48,11 +48,12 @@ export const NostrosAvatar: React.FC<NostrosAvatarProps> = ({
<PaperAvatar.Text size={size} label={displayName.substring(0, 2).toUpperCase()} />
)}
</View>
{hasLud06 ? (
{hasLud06 && (
<PaperAvatar.Icon
size={lud06IconSize}
icon='lightning-bolt'
style={[
styles.iconLightning,
{
right: -(size - lud06IconSize),
backgroundColor: theme.colors.secondaryContainer,
@ -61,8 +62,6 @@ export const NostrosAvatar: React.FC<NostrosAvatarProps> = ({
]}
color='#F5D112'
/>
) : (
<View style={styles.iconLightning} />
)}
</View>
)
@ -70,7 +69,7 @@ export const NostrosAvatar: React.FC<NostrosAvatarProps> = ({
const styles = StyleSheet.create({
iconLightning: {
marginBottom: 16,
marginBottom: -16,
},
})

View File

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

View File

@ -25,9 +25,14 @@ import ProfileShare from '../ProfileShare'
interface ProfileActionsProps {
user: User
setUser: (user: User) => void
onActionDone?: () => void
}
export const ProfileActions: React.FC<ProfileActionsProps> = ({ user, setUser }) => {
export const ProfileActions: React.FC<ProfileActionsProps> = ({
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<ProfileActionsProps> = ({ user, setUser })
icon='message-plus-outline'
size={28}
onPress={() => {
onActionDone()
navigate('Conversation', {
pubKey: user.id,
title: username(user),

View File

@ -79,7 +79,7 @@ export const ProfileCard: React.FC<ProfileCardProps> = ({ bottomSheetRef, showIm
<Divider />
{user && (
<View style={styles.profileActions}>
<ProfileActions user={user} setUser={setUser} />
<ProfileActions user={user} setUser={setUser} onActionDone={() => bottomSheetRef.current?.close()}/>
</View>
)}
{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'
},
})

View File

@ -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<QueryResult | null> = 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<QueryResult | null> = async (
db,
) => {
const userQuery = `UPDATE nostros_direct_messages SET read = ?`
return db.execute(userQuery, [1])
}
export const getGroups: (
db: QuickSQLiteConnection
) => Promise<Group[]> = 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<Group> = 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
}

View File

@ -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",

View File

@ -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.",

View File

@ -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.",

View File

@ -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.",

View File

@ -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": "Вы подписались.",

View File

@ -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": "已关注",

View File

@ -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 = () => {
</View>
<View>
<Button onPress={() => (item.contact ? removeContact(item) : addContact(item))}>
{item.contact ? t('contactsFeed.stopFollowing') : t('contactsFeed.follow')}
{item.contact ? t('contactsPage.stopFollowing') : t('contactsPage.follow')}
</Button>
</View>
</View>
@ -232,7 +232,7 @@ export const ContactsFeed: React.FC = () => {
/>
</View>
<View>
<Button onPress={() => unblock(item)}>{t('contactsFeed.unblock')}</Button>
<Button onPress={() => unblock(item)}>{t('contactsPage.unblock')}</Button>
</View>
</View>
</TouchableRipple>
@ -269,13 +269,13 @@ export const ContactsFeed: React.FC = () => {
color={theme.colors.onPrimaryContainer}
/>
<Text variant='headlineSmall' style={styles.center}>
{t('contactsFeed.emptyTitleFollowing')}
{t('contactsPage.emptyTitleFollowing')}
</Text>
<Text variant='bodyMedium' style={styles.center}>
{t('contactsFeed.emptyDescriptionFollowing')}
{t('contactsPage.emptyDescriptionFollowing')}
</Text>
<Button mode='contained' compact onPress={() => bottomSheetAddContactRef.current?.open()}>
{t('contactsFeed.emptyButtonFollowing')}
{t('contactsPage.emptyButtonFollowing')}
</Button>
</View>
)
@ -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}
/>
<Text variant='headlineSmall' style={styles.center}>
{t('contactsFeed.emptyTitleFollower')}
{t('contactsPage.emptyTitleFollower')}
</Text>
<Text variant='bodyMedium' style={styles.center}>
{t('contactsFeed.emptyDescriptionFollower')}
{t('contactsPage.emptyDescriptionFollower')}
</Text>
<Button
mode='contained'
@ -316,7 +317,7 @@ export const ContactsFeed: React.FC = () => {
Clipboard.setString(nPub ?? '')
}}
>
{t('contactsFeed.emptyButtonFollower')}
{t('contactsPage.emptyButtonFollower')}
</Button>
</View>
)
@ -345,10 +346,10 @@ export const ContactsFeed: React.FC = () => {
color={theme.colors.onPrimaryContainer}
/>
<Text variant='headlineSmall' style={styles.center}>
{t('contactsFeed.emptyTitleBlocked')}
{t('contactsPage.emptyTitleBlocked')}
</Text>
<Text variant='bodyMedium' style={styles.center}>
{t('contactsFeed.emptyDescriptionBlocked')}
{t('contactsPage.emptyDescriptionBlocked')}
</Text>
</View>
)
@ -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 = () => {
>
<TouchableRipple style={styles.textWrapper} onPress={() => setTabKey('following')}>
<Text style={styles.tabText}>
{t('contactsFeed.following', { count: following.length })}
{t('contactsPage.following', { count: following.length })}
</Text>
</TouchableRipple>
</View>
@ -400,7 +402,7 @@ export const ContactsFeed: React.FC = () => {
>
<TouchableRipple style={styles.textWrapper} onPress={() => setTabKey('followers')}>
<Text style={styles.tabText}>
{t('contactsFeed.followers', { count: followers.length })}
{t('contactsPage.followers', { count: followers.length })}
</Text>
</TouchableRipple>
</View>
@ -414,7 +416,7 @@ export const ContactsFeed: React.FC = () => {
>
<TouchableRipple style={styles.textWrapper} onPress={() => setTabKey('blocked')}>
<Text style={styles.tabText}>
{t('contactsFeed.blocked', { count: blocked.length })}
{t('contactsPage.blocked', { count: blocked.length })}
</Text>
</TouchableRipple>
</View>
@ -422,7 +424,7 @@ export const ContactsFeed: React.FC = () => {
{renderScene[tabKey]}
{privateKey && (
<AnimatedFAB
style={[styles.fab, { top: Dimensions.get('window').height - 216 }]}
style={styles.fab}
icon='account-multiple-plus-outline'
label='Label'
onPress={() => bottomSheetAddContactRef.current?.open()}
@ -438,12 +440,12 @@ export const ContactsFeed: React.FC = () => {
onClose={() => setContactInput('')}
>
<View>
<Text variant='titleLarge'>{t('contactsFeed.addContactTitle')}</Text>
<Text variant='bodyMedium'>{t('contactsFeed.addContactDescription')}</Text>
<Text variant='titleLarge'>{t('contactsPage.addContactTitle')}</Text>
<Text variant='bodyMedium'>{t('contactsPage.addContactDescription')}</Text>
<TextInput
style={styles.input}
mode='outlined'
label={t('contactsFeed.addContact') ?? ''}
label={t('contactsPage.addContact') ?? ''}
onChangeText={setContactInput}
value={contactInput}
right={
@ -461,7 +463,7 @@ export const ContactsFeed: React.FC = () => {
onPress={onPressAddContact}
loading={isAddingContact}
>
{t('contactsFeed.addContact')}
{t('contactsPage.addContact')}
</Button>
<Button
mode='outlined'
@ -470,7 +472,7 @@ export const ContactsFeed: React.FC = () => {
setContactInput('')
}}
>
{t('contactsFeed.cancel')}
{t('contactsPage.cancel')}
</Button>
</View>
</RBSheet>
@ -482,7 +484,7 @@ export const ContactsFeed: React.FC = () => {
onIconPress={() => setShowNotification(undefined)}
onDismiss={() => setShowNotification(undefined)}
>
{t(`contactsFeed.notifications.${showNotification}`)}
{t(`contactsPage.notifications.${showNotification}`)}
</Snackbar>
)}
</>
@ -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

View File

@ -37,6 +37,7 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ route }) =>
const { publicKey, privateKey, name, picture } = useContext(UserContext)
const otherPubKey = useMemo(() => route.params.pubKey, [])
const [pageSize, setPageSize] = useState<number>(initialPageSize)
const [decryptedMessages, setDecryptedMessages] = useState<Record<string, string>>({})
const [directMessages, setDirectMessages] = useState<DirectMessage[]>([])
const [sendingMessages, setSendingMessages] = useState<DirectMessage[]>([])
const [otherUser, setOtherUser] = useState<User>({ id: otherPubKey })
@ -74,16 +75,22 @@ export const ConversationPage: React.FC<ConversationPageProps> = ({ 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()

View File

@ -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%',

View File

@ -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-') && (
<Appbar.Action icon='check-all' isLeading onPress={() => onPressCheckAll()} />
)}
{['GroupPage'].includes(route.name) && (
<GroupHeaderIcon groupId={route.params?.groupId}/>
)}
</Appbar.Header>
)
},
@ -131,8 +137,10 @@ 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.Group>
<Stack.Group>
<Stack.Screen name='Contacts' component={ContactsPage} />
<Stack.Screen name='Relays' component={RelaysPage} />
<Stack.Screen name='About' component={AboutPage} />
<Stack.Screen name='Config' component={ConfigPage} />

View File

@ -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<GroupPageProps> = ({ route }) => {
const initialPageSize = 10
const theme = useTheme()
const scrollViewRef = useRef<ScrollView>()
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<number>(initialPageSize)
const [directMessages, setDirectMessages] = useState<DirectMessage[]>([])
const [sendingMessages, setSendingMessages] = useState<DirectMessage[]>([])
const [otherUser, setOtherUser] = useState<User>({ id: otherPubKey })
const [input, setInput] = useState<string>('')
const [showNotification, setShowNotification] = useState<string>()
const [startUpload, setStartUpload] = useState<boolean>(false)
const [uploadingFile, setUploadingFile] = useState<boolean>(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<DirectMessage> = ({ 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 (
<View style={styles.messageRow}>
{publicKey !== item.pubkey && (
<View style={styles.pictureSpaceLeft}>
{showAvatar && (
<TouchableRipple onPress={() => setDisplayUserDrawer(otherPubKey)}>
<NostrosAvatar
name={otherUser.name}
pubKey={otherPubKey}
src={otherUser.picture}
size={40}
/>
</TouchableRipple>
)}
</View>
)}
<Card
style={[
styles.card,
// FIXME: can't find this color
{
backgroundColor:
publicKey === item.pubkey ? theme.colors.tertiaryContainer : '#001C37',
},
]}
>
<Card.Content>
<View style={styles.cardContentInfo}>
<Text>{displayName}</Text>
<View style={styles.cardContentDate}>
{item.pending && (
<View style={styles.cardContentPending}>
<MaterialCommunityIcons
name='clock-outline'
size={14}
color={theme.colors.onPrimaryContainer}
/>
</View>
)}
<Text>
{formatDistance(fromUnixTime(item.created_at), new Date(), { addSuffix: true })}
</Text>
</View>
</View>
<TextContent content={item.content} />
</Card.Content>
</Card>
{publicKey === item.pubkey && (
<View style={styles.pictureSpaceRight}>
{showAvatar && (
<TouchableRipple onPress={() => setDisplayUserDrawer(publicKey)}>
<NostrosAvatar name={name} pubKey={publicKey} src={picture} size={40} />
</TouchableRipple>
)}
</View>
)}
</View>
)
}
const onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void = (event) => {
if (handleInfinityScroll(event)) {
setPageSize(pageSize + initialPageSize)
}
}
return (
<View style={styles.container}>
<FlashList
inverted
data={[...sendingMessages, ...directMessages]}
renderItem={renderDirectMessageItem}
horizontal={false}
ref={scrollViewRef}
estimatedItemSize={100}
onScroll={onScroll}
/>
<View
style={[
styles.input,
{
backgroundColor: '#001C37',
},
]}
>
<TextInput
mode='outlined'
multiline
label={t('conversationPage.typeMessage') ?? ''}
value={input}
onChangeText={setInput}
onFocus={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
left={
<TextInput.Icon
icon={() => (
<MaterialCommunityIcons
name='image-outline'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
onPress={() => setStartUpload(true)}
/>
}
right={
<TextInput.Icon
icon={() => (
<MaterialCommunityIcons
name='send-outline'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
onPress={send}
/>
}
/>
</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) => {
setInput((prev) => `${prev} ${imageUri}`)
setStartUpload(false)
}}
uploadingFile={uploadingFile}
setUploadingFile={setUploadingFile}
/>
</View>
)
}
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

View File

@ -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<RBSheet>(null)
const [groups, setGroups] = 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)
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<Group> = ({ item }) => {
return (
<TouchableRipple
onPress={() =>
navigate('GroupPage', {
groupId: item.id,
title: item.name || formatId(item.id),
})
}
>
<View style={styles.row}>
<View style={styles.groupData}>
<NostrosAvatar src={item.picture} name={item.name} pubKey={item.pubkey} />
<View style={styles.groupDescription}>
<Text>
{item.name && item.name?.length > 30 ? `${item.name?.slice(0, 30)}...` : item.name}
</Text>
<Text style={{ color: theme.colors.onSurfaceVariant }}>
{item.about && item.about?.length > 30
? `${item.about?.slice(0, 30)}...`
: item.about}
</Text>
</View>
</View>
<View>
<View style={styles.username}>
<Text>{username({ name: item.user_name, id: item.pubkey })}</Text>
{item.valid_nip05 && (
<MaterialCommunityIcons
name='check-decagram-outline'
color={theme.colors.onPrimaryContainer}
style={styles.verifyIcon}
/>
)}
</View>
<Text style={{ color: theme.colors.onSurfaceVariant }}>{formatId(item.id)}</Text>
</View>
</View>
</TouchableRipple>
)
}
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 = (
<View style={styles.blankNoButton}>
<MaterialCommunityIcons
name='account-group-outline'
size={64}
style={styles.center}
color={theme.colors.onPrimaryContainer}
/>
<Text variant='headlineSmall' style={styles.center}>
{t('contactsPage.emptyTitleBlocked')}
</Text>
<Text variant='bodyMedium' style={styles.center}>
{t('contactsPage.emptyDescriptionBlocked')}
</Text>
</View>
)
return (
<View style={styles.container}>
<FlashList
estimatedItemSize={71}
showsVerticalScrollIndicator={false}
data={groups}
renderItem={renderGroupItem}
ItemSeparatorComponent={Divider}
ListEmptyComponent={ListEmptyComponentBlocked}
horizontal={false}
/>
<AnimatedFAB
style={[styles.fab, { top: Dimensions.get('window').height - 216 }]}
icon='plus'
label='Label'
onPress={() => bottomSheetCreateRef.current?.open()}
animateFrom='right'
iconMode='static'
extended={false}
/>
<RBSheet ref={bottomSheetCreateRef} closeOnDragDown={true} customStyles={bottomSheetStyles}>
<View>
<Text style={styles.input} variant='titleLarge'>
{t('groupsFeed.createTitle')}
</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}
multiline
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)}
/>
}
/>
<Button mode='contained' disabled={!newGroupName} onPress={createNewGroup}>
{t('groupsFeed.newGroupCreate')}
</Button>
<UploadImage
startUpload={startUpload}
setImageUri={(imageUri) => {
setNewGroupPicture(imageUri)
setStartUpload(false)
}}
uploadingFile={uploadingFile}
setUploadingFile={setUploadingFile}
/>
</View>
</RBSheet>
</View>
)
}
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

View File

@ -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 && (
<Tab.Screen
name='messages'
component={ConversationsFeed}
options={{
tabBarIcon: ({ focused, size }) => (
<>
<>
<Tab.Screen
name='groups'
component={GroupsFeed}
options={{
tabBarIcon: ({ focused, size }) => (
<MaterialCommunityIcons
name={focused ? 'email' : 'email-outline'}
name={focused ? 'account-group' : 'account-group-outline'}
size={size}
color={theme.colors.onPrimaryContainer}
/>
{newdirectMessages > 0 && (
<Badge style={styles.notificationBadge}>{newdirectMessages}</Badge>
)}
</>
),
}}
/>
),
}}
/>
<Tab.Screen
name='messages'
component={ConversationsFeed}
options={{
tabBarIcon: ({ focused, size }) => (
<>
<MaterialCommunityIcons
name={focused ? 'email' : 'email-outline'}
size={size}
color={theme.colors.onPrimaryContainer}
/>
{newdirectMessages > 0 && (
<Badge style={styles.notificationBadge}>{newdirectMessages}</Badge>
)}
</>
),
}}
/>
</>
)}
<Tab.Screen
name='contacts'
component={ContactsFeed}
options={{
tabBarIcon: ({ focused, size }) => (
<MaterialCommunityIcons
name={focused ? 'account-group' : 'account-group-outline'}
size={size}
color={theme.colors.onPrimaryContainer}
/>
),
}}
/>
<Tab.Screen
name='notifications'
component={NotificationsFeed}

View File

@ -21,8 +21,11 @@ import RBSheet from 'react-native-raw-bottom-sheet'
import NostrosAvatar from '../../Components/NostrosAvatar'
import { getUnixTime } from 'date-fns'
import { useFocusEffect } from '@react-navigation/native'
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'
import UploadImage from '../../Components/UploadImage'
export const ProfileConfigPage: React.FC = () => {
const { t } = useTranslation('common')
const theme = useTheme()
const bottomSheetPictureRef = React.useRef<RBSheet>(null)
const bottomSheetDirectoryRef = React.useRef<RBSheet>(null)
@ -49,7 +52,8 @@ export const ProfileConfigPage: React.FC = () => {
// State
const [showNotification, setShowNotification] = useState<undefined | string>()
const [isPublishingProfile, setIsPublishingProfile] = useState<string>()
const { t } = useTranslation('common')
const [startUpload, setStartUpload] = useState<boolean>(false)
const [uploadingFile, setUploadingFile] = useState<boolean>(false)
useFocusEffect(
React.useCallback(() => {
@ -291,6 +295,18 @@ export const ProfileConfigPage: React.FC = () => {
forceTextInputFocus={false}
/>
}
left={
<TextInput.Icon
icon={() => (
<MaterialCommunityIcons
name='image-outline'
size={25}
color={theme.colors.onPrimaryContainer}
/>
)}
onPress={() => setStartUpload(true)}
/>
}
/>
<Button
mode='contained'
@ -405,6 +421,16 @@ export const ProfileConfigPage: React.FC = () => {
{t(`profileConfigPage.notifications.${showNotification}`, { nip05, lud06: lnurl })}
</Snackbar>
)}
<UploadImage
startUpload={startUpload}
setImageUri={(imageUri) => {
setPicture(imageUri)
setStartUpload(false)
}}
uploadingFile={uploadingFile}
setUploadingFile={setUploadingFile}
alert={false}
/>
</View>
)
}

View File

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