diff --git a/frontend/Components/ProfileActions/index.tsx b/frontend/Components/ProfileActions/index.tsx index 3465028..d8f4262 100644 --- a/frontend/Components/ProfileActions/index.tsx +++ b/frontend/Components/ProfileActions/index.tsx @@ -1,6 +1,6 @@ import { t } from 'i18next' import * as React from 'react' -import { StyleSheet, View, type ListRenderItem, Switch, FlatList } from 'react-native' +import { StyleSheet, View, type ListRenderItem, FlatList } from 'react-native' import { Button, IconButton, List, Snackbar, Text, useTheme } from 'react-native-paper' import { AppContext } from '../../Contexts/AppContext' import { RelayPoolContext } from '../../Contexts/RelayPoolContext' @@ -11,15 +11,15 @@ import LnPayment from '../LnPayment' import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons' import { navigate } from '../../lib/Navigation' import RBSheet from 'react-native-raw-bottom-sheet' -import { getUserRelays, type NoteRelay } from '../../Functions/DatabaseFunctions/NotesRelays' import { relayToColor } from '../../Functions/NativeFunctions' -import { type Relay } from '../../Functions/DatabaseFunctions/Relays' import ProfileShare from '../ProfileShare' import { ScrollView } from 'react-native-gesture-handler' import { Kind } from 'nostr-tools' import { getUnixTime } from 'date-fns' import DatabaseModule from '../../lib/Native/DatabaseModule' import { addMutedUsersList, removeMutedUsersList } from '../../Functions/RelayFunctions/Lists' +import { getRelayMetadata } from '../../Functions/DatabaseFunctions/RelayMetadatas' +import { getUserRelays } from '../../Functions/DatabaseFunctions/NotesRelays' interface ProfileActionsProps { user: User @@ -35,7 +35,8 @@ export const ProfileActions: React.FC = ({ const theme = useTheme() const { database } = React.useContext(AppContext) const { publicKey, privateKey, mutedUsers, reloadLists } = React.useContext(UserContext) - const { relayPool, updateRelayItem, lastEventId, sendEvent } = React.useContext(RelayPoolContext) + const { relayPool, addRelayItem, lastEventId, sendEvent, relays } = + React.useContext(RelayPoolContext) const [isContact, setIsContact] = React.useState() const [isMuted, setIsMuted] = React.useState() const [isGroupHidden, setIsGroupHidden] = React.useState() @@ -44,13 +45,13 @@ export const ProfileActions: React.FC = ({ const bottomSheetRelaysRef = React.useRef(null) const bottomSheetShareRef = React.useRef(null) const bottomSheetMuteRef = React.useRef(null) - const [userRelays, setUserRelays] = React.useState([]) + const [userRelays, setUserRelays] = React.useState() const [openLn, setOpenLn] = React.useState(false) React.useEffect(() => { loadUser() loadRelays() - if (publicKey) { + if (publicKey && user.id) { relayPool?.subscribe('lists-muted-users', [ { kinds: [10000], @@ -58,12 +59,20 @@ export const ProfileActions: React.FC = ({ limit: 1, }, ]) + relayPool?.subscribe(`card-user-${user.id.substring(0, 6)}`, [ + { + kinds: [10002], + authors: [user.id], + limit: 1, + }, + ]) } }, []) React.useEffect(() => { reloadLists() loadUser() + loadRelays() }, [lastEventId, isMuted]) const hideGroupsUser: () => void = () => { @@ -87,9 +96,13 @@ export const ProfileActions: React.FC = ({ const loadRelays: () => void = () => { if (database) { - getUserRelays(database, user.id).then((results) => { - if (results) { - setUserRelays(results) + getRelayMetadata(database, user.id).then((resultMeta) => { + if (resultMeta) { + setUserRelays(resultMeta.tags.map((relayMeta) => relayMeta[1])) + } else { + getUserRelays(database, user.id).then((resultRelays) => { + setUserRelays(resultRelays.map((relay) => relay.url)) + }) } }) } @@ -149,21 +162,6 @@ export const ProfileActions: React.FC = ({ } } - const activeRelay: (relay: Relay) => void = (relay) => { - relay.active = 1 - updateRelayItem(relay).then(() => { - setShowNotificationRelay('active') - }) - } - - const desactiveRelay: (relay: Relay) => void = (relay) => { - relay.active = 0 - relay.global_feed = 0 - updateRelayItem(relay).then(() => { - setShowNotificationRelay('desactive') - }) - } - const bottomSheetStyles = React.useMemo(() => { return { container: { @@ -178,25 +176,34 @@ export const ProfileActions: React.FC = ({ } }, []) - const renderRelayItem: ListRenderItem = ({ index, item }) => { + const onPressAddRelay: (url: string) => void = (url) => { + addRelayItem({ url }) + } + + const renderRelayItem: ListRenderItem = ({ index, item }) => { + const userRelayUrls = relays.map((relay) => relay.url) return ( ( - )} - right={() => ( - 0} - onValueChange={() => (item.active ? desactiveRelay(item) : activeRelay(item))} + color={relayToColor(item)} /> )} + right={() => { + if (userRelayUrls.includes(item)) { + return <> + } else { + return ( + + ) + } + }} /> ) } diff --git a/frontend/Components/TextContent/LinksPreview/index.tsx b/frontend/Components/TextContent/LinksPreview/index.tsx index 82362fc..acc50f9 100644 --- a/frontend/Components/TextContent/LinksPreview/index.tsx +++ b/frontend/Components/TextContent/LinksPreview/index.tsx @@ -50,17 +50,19 @@ export const LinksPreview: React.FC = ({ urls, lnUrl }) => { const getDefaultCover: () => number = () => { if (!firstLink || !urls[firstLink]) return require(DEFAULT_COVER) - if (urls[firstLink] === 'magnet') return require(MAGNET_COVER) - if (urls[firstLink] === 'blueBird') return require(BLUEBIRD_COVER) - if (urls[firstLink] === 'audio') return require(MEDIA_COVER) if (urls[firstLink] === 'video') return require(MEDIA_COVER) + if (urls[firstLink] === 'audio') return require(MEDIA_COVER) + if (urls[firstLink] === 'blueBird') return require(BLUEBIRD_COVER) + if (urls[firstLink] === 'tube') return require(MEDIA_COVER) + if (urls[firstLink] === 'magnet') return require(MAGNET_COVER) return require(DEFAULT_COVER) } const videoPreview = ( = ({ } } + const handleHashtagPress: (hashtag: string) => void = (hashtag) => { + if (hashtag) { + navigate('Search', { search: hashtag }) + } + } + const handleNip05ProfilePress: (nip19: string) => void = (nip19) => { const pubKey = getNip19Key(nip19) @@ -126,6 +137,8 @@ export const TextContent: React.FC = ({ return 'image' } else if (validBlueBirdUrl(url)) { return 'blueBird' + } else if (validTubeUrl(url)) { + return 'tube' } else if (MAGNET_LINK.test(url)) { return 'magnet' } @@ -174,7 +187,7 @@ export const TextContent: React.FC = ({ pattern: /#\[(\d+)\]/, style: styles.mention, }, - { pattern: /#(\w+)/, style: styles.hashTag }, + { pattern: /#(\w+)/, style: styles.hashTag, onPress: handleHashtagPress }, { pattern: /(lnbc)\S+/, style: styles.nip19, renderText: renderLnurl }, { pattern: /(nevent1)\S+/, style: styles.nip19, onPress: handleNip05NotePress }, { diff --git a/frontend/Functions/NativeFunctions/index.ts b/frontend/Functions/NativeFunctions/index.ts index f826f39..74cedd9 100644 --- a/frontend/Functions/NativeFunctions/index.ts +++ b/frontend/Functions/NativeFunctions/index.ts @@ -48,7 +48,7 @@ export const pickRandomItems = (arr: T[], n: number): T[] => export const validImageUrl: (url: string | undefined) => boolean = (url) => { if (url) { - const regexp = /^(https?:\/\/.*\.?(png|jpg|jpeg|gif|webp)(\?.*)?)$/ + const regexp = /^(https?:\/\/.*\.?(png|jpg|jpeg|gif|webp){1}(\?.*)?)$/ return regexp.test(url) } else { return false @@ -57,9 +57,15 @@ export const validImageUrl: (url: string | undefined) => boolean = (url) => { export const validMediaUrl: (url: string | undefined) => boolean = (url) => { if (url) { - const fileRegexp = /^(https?:\/\/.*\.?(mp4|mp3))$/ - const serviceRegexp = /^(https?:\/\/?(youtube|youtu.be).*)$/ - return fileRegexp.test(url) || serviceRegexp.test(url) + return /^(https?:\/\/.*\.(mp4|mp3){1})$/.test(url) + } else { + return false + } +} + +export const validTubeUrl: (url: string | undefined) => boolean = (url) => { + if (url) { + return /^(https?:\/\/.*\.?(youtube|youtu.be){1}.*)$/.test(url) } else { return false } @@ -67,7 +73,7 @@ export const validMediaUrl: (url: string | undefined) => boolean = (url) => { export const validBlueBirdUrl: (url: string | undefined) => boolean = (url) => { if (url) { - const serviceRegexp = /^(https?:\/\/?(twitter.com|t.co).*)$/ + const serviceRegexp = /^(https?:\/\/.*\.?(twitter.com|t.co){1}.*)$/ return serviceRegexp.test(url) } else { return false @@ -76,7 +82,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|note1)\S*$/ + const regexp = /^(nostr:)?(npub1|nprofile1|nevent1|nrelay1|note1){1}\S*$/ return regexp.test(string) } else { return false diff --git a/frontend/Locales/de.json b/frontend/Locales/de.json index 0b013fb..8cf7161 100644 --- a/frontend/Locales/de.json +++ b/frontend/Locales/de.json @@ -15,7 +15,7 @@ "searchPage": { "placeholder": "Look for public keys, notes...", "emptyTitle": "Tip", - "emptyDescription": "Start typing @ to find someone" + "emptyDescription": "Start typing @ to find someone\n\nStart typing # to search topics" }, "qrReaderPage": { "emptyTitle": "Berechtigung nicht gewährt", @@ -443,6 +443,7 @@ "userUnblocked": "Profil entblockt", "userBlocked": "Profile geblockt" }, + "addRelay": "Connect relay", "invoice": "Tip", "message": "Nachricht", "follow": "Folgen", diff --git a/frontend/Locales/en.json b/frontend/Locales/en.json index a8ff6ce..580e73d 100644 --- a/frontend/Locales/en.json +++ b/frontend/Locales/en.json @@ -35,9 +35,9 @@ "markAllRead": "Mark all as read" }, "searchPage": { - "placeholder": "Look for public keys, notes...", + "placeholder": "Look for public keys, notes, hashtags...", "emptyTitle": "Tip", - "emptyDescription": "Start typing @ to find someone" + "emptyDescription": "Start typing @ to find someone\n\nStart typing # to search topics" }, "conversationPage": { "unableDecypt": "{{username}} is talking with others about you", @@ -450,6 +450,7 @@ "userUnblocked": "Profile unblocked", "userBlocked": "Profile unblocked" }, + "addRelay": "Connect relay", "invoice": "Tip", "message": "Message", "follow": "Follow", diff --git a/frontend/Locales/es.json b/frontend/Locales/es.json index a21beb4..6062ca3 100644 --- a/frontend/Locales/es.json +++ b/frontend/Locales/es.json @@ -15,7 +15,7 @@ "searchPage": { "placeholder": "Busca for claves públicas, notas, ...", "emptyTitle": "Consejo", - "emptyDescription": "Empieza escribiendo con @ para encontrar a alguien" + "emptyDescription": "Empieza escribiendo con @ para encontrar a alguien.\n\nEmpieza escribiendo # para buscar temas." }, "qrReaderPage": { "emptyTitle": "Permisos no concedidos", @@ -430,6 +430,7 @@ "userUnblocked": "Perfil desbloqueado", "userBlocked": "Perfil bloqueado" }, + "addRelay": "Connectar a relay", "invoice": "Propina", "message": "Mensaje", "follow": "Seguir", diff --git a/frontend/Locales/fr.json b/frontend/Locales/fr.json index f513c8b..4618f19 100644 --- a/frontend/Locales/fr.json +++ b/frontend/Locales/fr.json @@ -13,9 +13,9 @@ "privateKeysSnackbarDescription": "Conservez votre clé privée dans un endroit sûr. Si vous la perdez, vous ne pourrez plus avoir accès ni récupérer votre compte." }, "searchPage": { - "placeholder": "Look for public keys, notes...", + "placeholder": "Look for public keys, notes, hashtags...", "emptyTitle": "Tip", - "emptyDescription": "Start typing @ to find someone" + "emptyDescription": "Start typing @ to find someone\n\nStart typing # to search topics" }, "qrReaderPage": { "emptyTitle": "Permissions not granted", @@ -414,6 +414,7 @@ "userUnblocked": "Profile unblocked", "userBlocked": "Profile unblocked" }, + "addRelay": "Connect relay", "invoice": "Tip", "message": "Message", "follow": "Abonné", diff --git a/frontend/Locales/ru.json b/frontend/Locales/ru.json index fcda996..cd0c4a0 100644 --- a/frontend/Locales/ru.json +++ b/frontend/Locales/ru.json @@ -13,9 +13,9 @@ "privateKeysSnackbarDescription": "Keep your private key in a safe place, if you lose it you will not be able to access it again or recover your account." }, "searchPage": { - "placeholder": "Look for public keys, notes...", + "placeholder": "Look for public keys, notes, hashtags...", "emptyTitle": "Tip", - "emptyDescription": "Start typing @ to find someone" + "emptyDescription": "Start typing @ to find someone\n\nStart typing # to search topics" }, "qrReaderPage": { "emptyTitle": "Permissions not granted", @@ -422,6 +422,7 @@ "userUnblocked": "Profile unblocked", "userBlocked": "Profile unblocked" }, + "addRelay": "Connect relay", "invoice": "Tip", "message": "Message", "follow": "Follow", diff --git a/frontend/Locales/zhCn.json b/frontend/Locales/zhCn.json index ca45e36..7091640 100644 --- a/frontend/Locales/zhCn.json +++ b/frontend/Locales/zhCn.json @@ -13,9 +13,9 @@ "privateKeysSnackbarDescription": "请妥善保管您的私钥。如果遗失,您将无法访问或恢复您的账号。" }, "searchPage": { - "placeholder": "Look for public keys, notes...", + "placeholder": "Look for public keys, notes, hashtags...", "emptyTitle": "Tip", - "emptyDescription": "Start typing @ to find someone" + "emptyDescription": "Start typing @ to find someone\n\nStart typing # to search topics" }, "qrReaderPage": { "emptyTitle": "未授予权限", @@ -423,6 +423,7 @@ "userUnblocked": "已屏蔽", "userBlocked": "已取消屏蔽" }, + "addRelay": "Connect relay", "invoice": "赞赏", "message": "私信", "follow": "关注", diff --git a/frontend/Pages/ProfileLoadPage/FirstStep/index.tsx b/frontend/Pages/ProfileLoadPage/FirstStep/index.tsx index 130e4a5..3e2ccc3 100644 --- a/frontend/Pages/ProfileLoadPage/FirstStep/index.tsx +++ b/frontend/Pages/ProfileLoadPage/FirstStep/index.tsx @@ -74,7 +74,9 @@ export const FirstStep: React.FC = ({ nextStep }) => { relays.forEach((relay) => { removeRelayItem(relay) }) - metadata.tags.forEach(async (tag) => await addRelayItem({ url: tag[1] })) + metadata.tags.forEach(async (tag) => { + if (tag[0] === 'r') await addRelayItem({ url: tag[1] }) + }) nextStep() } } diff --git a/frontend/Pages/SearchPage/index.tsx b/frontend/Pages/SearchPage/index.tsx index 6679ae4..aa35862 100644 --- a/frontend/Pages/SearchPage/index.tsx +++ b/frontend/Pages/SearchPage/index.tsx @@ -1,6 +1,8 @@ import { useFocusEffect } from '@react-navigation/native' import { FlashList, ListRenderItem } from '@shopify/flash-list' import { t } from 'i18next' +import debounce from 'lodash.debounce' +import { Kind } from 'nostr-tools' import { decode } from 'nostr-tools/nip19' import * as React from 'react' import { StyleSheet, View } from 'react-native' @@ -9,6 +11,7 @@ import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityI import NoteCard from '../../Components/NoteCard' import ProfileData from '../../Components/ProfileData' import { AppContext } from '../../Contexts/AppContext' +import { RelayPoolContext } from '../../Contexts/RelayPoolContext' import { getNotes, Note } from '../../Functions/DatabaseFunctions/Notes' import { getUsers, User } from '../../Functions/DatabaseFunctions/Users' import { validNip21 } from '../../Functions/NativeFunctions' @@ -16,17 +19,19 @@ import { navigate } from '../../lib/Navigation' import { getNpub } from '../../lib/nostr/Nip19' interface SearchPageProps { - route: { params: { urls: string[]; index?: number } } + route: { params: { search: string } } } export const SearchPage: React.FC = ({ route }) => { + const pageSize = 30 const theme = useTheme() const { database } = React.useContext(AppContext) + const { relayPool, lastEventId } = React.useContext(RelayPoolContext) const [users, setUsers] = React.useState([]) const [resultsUsers, setResultsUsers] = React.useState([]) const [notes, setNotes] = React.useState([]) const [resultsNotes, setResultsNotes] = React.useState([]) - const [searchInput, setSearchInput] = React.useState('') + const [searchInput, setSearchInput] = React.useState(route?.params?.search ?? '') const inputRef = React.useRef(null) useFocusEffect( @@ -46,40 +51,77 @@ export const SearchPage: React.FC = ({ route }) => { ) React.useEffect(() => { - if (searchInput !== '') { + if (/^#.*/.test(searchInput)) { const search = searchInput.toLocaleLowerCase() - if (/^@.*/.test(search)) { - const searchUser = search.replace(/^@/, '') - setResultsUsers( - users.filter( - (user) => - user.name?.toLocaleLowerCase().includes(searchUser) ?? - user.nip05?.toLocaleLowerCase().includes(searchUser), - ), - ) - } else { - if (validNip21(search)) { - try { - const key = decode(search.replace('nostr:', '')) - if (key?.data) { - if (key.type === 'nevent') { - setSearchInput('') - navigate('Note', { noteId: key.data.id }) - } else if (key.type === 'npub') { - setSearchInput('') - navigate('Profile', { pubKey: key.data }) - } else if (key.type === 'nprofile' && key.data.pubkey) { - setSearchInput('') - navigate('Profile', { pubKey: key.data.pubkey }) - } - } - } catch {} - } - setResultsNotes(notes.filter((note) => note.content.toLocaleLowerCase().includes(search))) + setResultsNotes( + notes.filter((note) => note.content.toLocaleLowerCase().includes(search.trim())), + ) + } + }, [lastEventId]) + + const subscribeHandler = React.useMemo( + () => + debounce((hastags) => { + relayPool?.subscribe('search-hastags', [ + { + kinds: [Kind.Text], + '#t': hastags, + limit: pageSize, + }, + ]) + }, 600), + [pageSize], + ) + + React.useEffect(() => { + if (/^#.*/.test(searchInput)) { + const hastags = [...searchInput.matchAll(/#([^#]\S+)/gi)].map((match) => { + return match[1] + }) + if (hastags.length > 0) { + subscribeHandler(hastags) } } }, [searchInput]) + React.useEffect(() => { + if (/^@.*/.test(searchInput)) { + const searchUser = searchInput.replace(/^@/, '') + setResultsUsers( + users.filter( + (user) => + user.name?.toLocaleLowerCase().includes(searchUser) ?? + user.nip05?.toLocaleLowerCase().includes(searchUser), + ), + ) + } else { + const search = searchInput.toLocaleLowerCase() + setResultsNotes( + notes.filter((note) => note.content.toLocaleLowerCase().includes(search.trim())), + ) + } + }, [searchInput, notes]) + + React.useEffect(() => { + if (searchInput !== '' && validNip21(searchInput)) { + try { + const key = decode(searchInput.replace('nostr:', '')) + if (key?.data) { + if (key.type === 'nevent') { + setSearchInput('') + navigate('Note', { noteId: key.data.id }) + } else if (key.type === 'npub') { + setSearchInput('') + navigate('Profile', { pubKey: key.data }) + } else if (key.type === 'nprofile' && key.data.pubkey) { + setSearchInput('') + navigate('Profile', { pubKey: key.data.pubkey }) + } + } + } catch {} + } + }, [searchInput]) + const renderItemNote: ListRenderItem = ({ item, index }) => { return ( @@ -173,7 +215,8 @@ const styles = StyleSheet.create({ paddingTop: 16, }, container: { - padding: 16, + paddingLeft: 16, + paddingRight: 16, flex: 1, }, inputContainer: { diff --git a/frontend/Pages/SendPage/index.tsx b/frontend/Pages/SendPage/index.tsx index 496af4e..d96dceb 100644 --- a/frontend/Pages/SendPage/index.tsx +++ b/frontend/Pages/SendPage/index.tsx @@ -97,6 +97,10 @@ export const SendPage: React.FC = ({ route }) => { }) } + ;[...rawContent.matchAll(/#([^#]\S+)/gi)].forEach((match) => { + if (match[1]) tags.push(['t', match[1]]) + }) + const event: Event = { content: rawContent, created_at: getUnixTime(new Date()), @@ -104,6 +108,7 @@ export const SendPage: React.FC = ({ route }) => { pubkey: publicKey, tags, } + sendEvent(event).catch(() => {}) } } diff --git a/frontend/lib/nostr/RelayPool/intex.ts b/frontend/lib/nostr/RelayPool/intex.ts index eb8f902..2ec6fbb 100644 --- a/frontend/lib/nostr/RelayPool/intex.ts +++ b/frontend/lib/nostr/RelayPool/intex.ts @@ -7,6 +7,7 @@ export interface RelayFilters { kinds?: number[] '#e'?: string[] '#p'?: string[] + '#t'?: string[] since?: number limit?: number until?: number