From a33016dee8024cc0f5f1bbc840c3f57b25a72c07 Mon Sep 17 00:00:00 2001 From: Bojan Mojsilovic Date: Thu, 4 Jul 2024 18:18:10 +0200 Subject: [PATCH] First pass at advanced search --- package-lock.json | 8 +- package.json | 1 + src/components/Search/SearchUsers.tsx | 167 ++++++++++++ src/pages/AdvancedSearch.tsx | 377 ++++++++++++++++++++++++-- src/pages/AdvancedSearchSelect.tsx | 56 ++++ src/pages/FeedsTest.module.scss | 114 ++++++++ 6 files changed, 705 insertions(+), 18 deletions(-) create mode 100644 src/components/Search/SearchUsers.tsx create mode 100644 src/pages/AdvancedSearchSelect.tsx diff --git a/package-lock.json b/package-lock.json index b4919d77..bd1b888f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@types/markdown-it-container": "^2.0.10", "@types/markdown-it-emoji": "^3.0.1", "@types/markdown-it-footnote": "^3.0.4", + "dayjs": "^1.11.11", "dompurify": "3.0.5", "highlight.js": "^11.9.0", "light-bolt11-decoder": "^3.1.1", @@ -47,7 +48,7 @@ "markdown-it-sub": "^2.0.0", "markdown-it-sup": "^2.0.0", "medium-zoom": "1.0.8", - "nostr-tools": "^2.7.0", + "nostr-tools": "2.7.0", "photoswipe": "5.4.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", "qr-code-styling": "^1.6.0-rc.1", @@ -2814,6 +2815,11 @@ "version": "3.1.2", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", + "integrity": "sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==" + }, "node_modules/debug": { "version": "4.3.4", "license": "MIT", diff --git a/package.json b/package.json index e662abd1..6044cd0b 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/markdown-it-container": "^2.0.10", "@types/markdown-it-emoji": "^3.0.1", "@types/markdown-it-footnote": "^3.0.4", + "dayjs": "^1.11.11", "dompurify": "3.0.5", "highlight.js": "^11.9.0", "light-bolt11-decoder": "^3.1.1", diff --git a/src/components/Search/SearchUsers.tsx b/src/components/Search/SearchUsers.tsx new file mode 100644 index 00000000..173209b3 --- /dev/null +++ b/src/components/Search/SearchUsers.tsx @@ -0,0 +1,167 @@ +import { useIntl } from '@cookbook/solid-intl'; +import { useNavigate } from '@solidjs/router'; +import { Component, createEffect, createSignal, For, Show } from 'solid-js'; +import { useSearchContext } from '../../contexts/SearchContext'; +import { nip05Verification, userName } from '../../stores/profile'; +import { PrimalUser } from '../../types/primal'; +import { debounce } from '../../utils'; +import Avatar from '../Avatar/Avatar'; +import Loader from '../Loader/Loader'; +import { useToastContext } from '../Toaster/Toaster'; +import { placeholders, search as t } from '../../translations'; + +import styles from './Search.module.scss'; +import SearchOption from './SearchOption'; +import { hookForDev } from '../../lib/devTools'; +import { useProfileContext } from '../../contexts/ProfileContext'; + + +const SearchUsers: Component<{ + onInputConfirm?: (query: string) => void, + onUserSelect?: (selected: PrimalUser | string) => void, + noLinks?: boolean, + hideDefault?: boolean, + placeholder?: string, + id?: string, +}> = (props) => { + + const toaster = useToastContext(); + const search = useSearchContext(); + const navigate = useNavigate(); + const intl = useIntl(); + const profile = useProfileContext(); + + const [query, setQuery] = createSignal(''); + const [isFocused, setIsFocused] = createSignal(false); + + const queryUrl = () => query().replaceAll('#', '%23'); + + let input: HTMLInputElement | undefined; + + const onSearch = (e: SubmitEvent) => { + e.preventDefault(); + return false; + + // const form = e.target as HTMLFormElement; + + // const data = new FormData(form); + + // const q = data.get('searchQuery') as string || ''; + + // if (q.length > 0) { + // if (props.onInputConfirm) { + // props.onInputConfirm(q); + // } + // else { + // navigate(`/search/${q.replaceAll('#', '%23')}`); + // } + // onBlur(); + // resetQuery(); + // } + // else { + // toaster?.sendInfo(intl.formatMessage(t.invalid)) + // } + // return false; + } + + const onInput = (e: InputEvent) => { + setIsFocused(true); + debounce(() => { + // @ts-ignore + const value = e.target?.value; + + if (value.startsWith('npub') || value.startsWith('nprofile')) { + search?.actions.findUserByNupub(value); + return; + } + + setQuery(value || ''); + }, 500); + }; + + const onFocus = (e: FocusEvent) => { + setIsFocused(true); + } + + const onBlur = (e?: FocusEvent) => { + setTimeout(() => { + setIsFocused(false); + }, 200); + } + + const resetQuery = () => { + setQuery(''); + + if (input) { + input.value = ''; + } + }; + + const selectUser = (user: PrimalUser) => { + if (props.onUserSelect) { + props.onUserSelect(user); + } + resetQuery(); + } + + createEffect(() => { + if (query().length === 0) { + search?.actions.getRecomendedUsers(profile?.profileHistory.profiles || []); + return; + } + + search?.actions.findUsers(query()); + }); + + return ( +
+
+
+ +
+ +
+ 0}> +
+
+ +
+
+
+ + + {(user) => ( + } + statNumber={profile?.profileHistory.stats[user.pubkey]?.followers_count || search?.scores[user.pubkey]} + statLabel={intl.formatMessage(t.followers)} + onClick={() => selectUser(user)} + /> + )} + +
+
+ ) +} + +export default hookForDev(SearchUsers); diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 7b314ebd..17ade38c 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,42 +1,122 @@ +import { Select, TextField } from '@kobalte/core'; import { A } from '@solidjs/router'; -import { Component, createEffect, For, onMount } from 'solid-js'; +import { Component, createEffect, For, onMount, Show } from 'solid-js'; import { createStore } from 'solid-js/store'; +import { style } from 'solid-js/web'; +import Avatar from '../components/Avatar/Avatar'; import ButtonPrimary from '../components/Buttons/ButtonPrimary'; import PageCaption from '../components/PageCaption/PageCaption'; import PageTitle from '../components/PageTitle/PageTitle'; import Search from '../components/Search/Search'; +import SearchUsers from '../components/Search/SearchUsers'; import StickySidebar from '../components/StickySidebar/StickySidebar'; import TextInput from '../components/TextInput/TextInput'; import Wormhole from '../components/Wormhole/Wormhole'; +import { userName } from '../stores/profile'; +import { PrimalUser } from '../types/primal'; +import AdvancedSearchSelectBox from './AdvancedSearchSelect'; import styles from './FeedsTest.module.scss'; +import dayjs from 'dayjs'; export type SearchState = { includes: string, excludes: string, hashtags: string, - postedBy: string, - replingTo: string, - userMentions: string, - following: string, + postedBy: PrimalUser[], + replingTo: PrimalUser[], + userMentions: PrimalUser[], + following: PrimalUser[], timeframe: string, sentiment: string, - kind: number, + kind: string, + orientation: string, + duration: string, command: string, } +const orientationKinds = ['Video', 'Images']; +const durationKinds = ['Video', 'Sound', 'Reads']; + + +const timeframes: Record string> = { + 'Anytime': () => '', + + 'past hour': () => { + const date = dayjs(); + const result = date.subtract(1, 'hour'); + + return `since:${result.format('YYYY-MM-DD_HH:mm')}`; + }, + + 'past 24 hours': () => { + const date = dayjs(); + const result = date.subtract(1, 'day'); + + return `since:${result.format('YYYY-MM-DD_HH:mm')}`; + }, + + 'past week': () => { + const date = dayjs(); + const result = date.subtract(1, 'week'); + + return `since:${result.format('YYYY-MM-DD_HH:mm')}`; + }, + + 'past month': () => { + const date = dayjs(); + const result = date.subtract(1, 'month'); + + return `since:${result.format('YYYY-MM-DD_HH:mm')}`; + }, + + 'past year': () => { + const date = dayjs(); + const result = date.subtract(1, 'year'); + + return `since:${result.format('YYYY-MM-DD_HH:mm')}`; + }, + + 'custom': () =>'', +}; + +const sentiments: Record string> = { + 'Positive': () => ':)', + 'Negative': () => ':(', + 'Question': () => '?', + 'Neutral': () => '', +}; + +const kinds: Record string> = { + 'All': () => '', + 'Notes': () => 'kind:1', + 'Replies': () => 'kind:1 filter:replies', + 'Reads': () => 'kind:30023', + 'Reads comments': () => 'kind:30023 filter:replies', + 'Images': () => 'filter:image', + 'Video': () => 'filter:video', + 'Sound': () => 'filter:audio', + 'Zaps': () => 'kind:9735', + 'People': () => 'kind:0', +}; + +const orientations = ['Any', 'Vertical', 'Horizontal']; + + const AdvancedSearch: Component = () => { const [state, setState] = createStore({ includes: '', excludes: '', hashtags: '', - postedBy: '', - replingTo: '', - userMentions: '', - following: '', - timeframe: '', - sentiment: '', - kind: 0, + postedBy: [], + replingTo: [], + userMentions: [], + following: [], + timeframe: 'Anytime', + sentiment: 'Neutral', + kind: 'All', + orientation: 'Any', + duration: '', command: '', }); @@ -48,16 +128,109 @@ const AdvancedSearch: Component = () => { createEffect(() => { const includes = state.includes.length === 0 ? '' : state.includes.split(',').map(x => x.trim()).reduce((acc, x) => `${acc}${x} `, ''); - const excludes = state.excludes.length === 0 ? '' : state.excludes.split(',').map(x => x.trim()).reduce((acc, x) => `${acc}~${x} `, '');; + const excludes = state.excludes.length === 0 ? '' : state.excludes.split(',').map(x => x.trim()).reduce((acc, x) => `${acc}-${x} `, '');; const hashtags = state.hashtags.length === 0 ? '' : state.hashtags.split(',').map(x => { const y = x.trim(); return y.startsWith('#') ? y : `#${y}`; }).reduce((acc, x) => `${acc}${x} `, ''); + const froms = state.postedBy.reduce((acc, u) => acc + 'from:' + u.npub + ' ', ''); + const tos = state.replingTo.reduce((acc, u) => acc + 'to:' + u.npub + ' ', '');; + const mentions = state.userMentions.reduce((acc, u) => acc + '@' + u.npub + ' ', '');; + const followings = state.following.reduce((acc, u) => acc + 'following:' + u.npub + ' ', ''); + + const since = `${timeframes[state.timeframe]()} `; + + const sentiment = `${sentiments[state.sentiment]()} `; - setState('command', () => `${includes}${excludes}${hashtags}`.trim()); + const kind = `${kinds[state.kind]()} `; + + const orient = orientationKinds.includes(state.kind) && state.orientation !== 'Any' ? + `orientation:${state.orientation.toLowerCase()} ` : + ''; + + + const parsedDuration = parseInt(state.duration); + + let duration = durationKinds.includes(state.kind) && !isNaN(parsedDuration) && parsedDuration > 0 ? + `duration:${state.duration} ` : + ''; + + setState('command', () => `${includes}${excludes}${hashtags}${froms}${tos}${mentions}${followings}${since}${sentiment}${kind}${orient}${duration}`.trim()); }) + const addFrom = (user: PrimalUser | undefined) => { + if (!user) return; + + setState('postedBy', state.postedBy.length, () => ({ ...user })) + } + + const removeFrom = (user: PrimalUser | undefined) => { + if (!user) return; + + const filtered = state.postedBy.filter(u => u.npub !== user.npub); + + setState('postedBy', () => [...filtered]); + } + + const addReply = (user: PrimalUser | undefined) => { + if (!user) return; + + setState('replingTo', state.replingTo.length, () => ({ ...user })) + } + + const removeReply = (user: PrimalUser | undefined) => { + if (!user) return; + + const filtered = state.replingTo.filter(u => u.npub !== user.npub); + + setState('replingTo', () => [...filtered]); + } + + const addMention = (user: PrimalUser | undefined) => { + if (!user) return; + + setState('userMentions', state.userMentions.length, () => ({ ...user })) + } + + const removeMention = (user: PrimalUser | undefined) => { + if (!user) return; + + const filtered = state.userMentions.filter(u => u.npub !== user.npub); + + setState('userMentions', () => [...filtered]); + } + + const addFollow = (user: PrimalUser | undefined) => { + if (!user) return; + + setState('following', state.following.length, () => ({ ...user })) + } + + const removeFollow = (user: PrimalUser | undefined) => { + if (!user) return; + + const filtered = state.following.filter(u => u.npub !== user.npub); + + setState('following', () => [...filtered]); + } + + const setTimeframe = (timeframe: string) => { + setState('timeframe', () => timeframe); + }; + + const setSentiment = (sentiment: string) => { + setState('sentiment', () => sentiment); + }; + + const setKind = (kind: string) => { + setState('kind', () => kind); + }; + + const setOrientation = (orient: string) => { + setState('orientation', () => orient); + }; + return ( <> @@ -69,7 +242,10 @@ const AdvancedSearch: Component = () => { -