personal web client for Bluesky
typescript solidjs bluesky atcute

feat: saved search

mary.my.id 70e9dc00 c4c4294c

verified
Changed files
+165 -14
src
components
lib
preferences
views
+47 -11
src/components/explore/my-feeds-section.tsx
··· 4 4 5 5 import type { SavedFeed } from '~/lib/preferences/account'; 6 6 import { useSession } from '~/lib/states/session'; 7 + import { assertUnreachable } from '~/lib/utils/invariant'; 7 8 import { reconcile } from '~/lib/utils/misc'; 8 9 9 10 import Avatar from '../avatar'; 11 + import MagnifyingGlassOutlinedIcon from '../icons-central/magnifying-glass-outline'; 10 12 11 13 const MyFeedsSection = () => { 12 14 const { currentAccount } = useSession(); ··· 16 18 } 17 19 18 20 const feeds = createMemo((prev: SavedFeed[] | undefined) => { 19 - return reconcile(prev, currentAccount.preferences.feeds, (item) => item.info.uri); 21 + return reconcile(prev, currentAccount.preferences.feeds, (feed) => { 22 + switch (feed.type) { 23 + case 'generator': 24 + case 'list': { 25 + return `${feed.type}:${feed.info.uri}`; 26 + } 27 + case 'search': { 28 + return `${feed.type}:${feed.query}:${feed.kind}`; 29 + } 30 + } 31 + }); 20 32 }); 21 33 22 34 return ( ··· 36 48 {(feed) => { 37 49 const type = feed.type; 38 50 39 - let href: string | undefined; 40 - { 41 - const uri = parseAtUri(feed.info.uri); 42 - if (type === 'generator') { 51 + let href: string; 52 + switch (type) { 53 + case 'generator': { 54 + const uri = parseAtUri(feed.info.uri); 43 55 href = `/${uri.repo}/feeds/${uri.rkey}`; 44 - } else if (type === 'list') { 56 + break; 57 + } 58 + case 'list': { 59 + const uri = parseAtUri(feed.info.uri); 45 60 href = `/${uri.repo}/lists/${uri.rkey}`; 61 + break; 62 + } 63 + case 'search': { 64 + href = `/search?q=${encodeURIComponent(feed.query)}&t=${feed.kind}`; 65 + break; 66 + } 67 + default: { 68 + assertUnreachable(feed); 46 69 } 47 70 } 48 71 ··· 51 74 href={href} 52 75 class="flex items-center gap-4 px-4 py-3 hover:bg-contrast/sm-pressed active:bg-contrast/md" 53 76 > 54 - <Avatar type={feed.type} src={feed.info.avatar} /> 77 + {type === 'generator' || type === 'list' ? ( 78 + <Avatar type={type} src={feed.info.avatar} /> 79 + ) : type === 'search' ? ( 80 + <div class="grid h-9 w-9 place-items-center rounded-md bg-accent text-xl text-accent-fg"> 81 + <MagnifyingGlassOutlinedIcon /> 82 + </div> 83 + ) : null} 84 + 55 85 <span class="text-sm font-bold"> 56 86 {(() => { 57 - if (type === 'generator') { 58 - return feed.info.displayName; 59 - } else if (type === 'list') { 60 - return feed.info.name; 87 + switch (type) { 88 + case 'generator': { 89 + return feed.info.displayName; 90 + } 91 + case 'list': { 92 + return feed.info.name; 93 + } 94 + case 'search': { 95 + return feed.name || feed.query; 96 + } 61 97 } 62 98 })()} 63 99 </span>
+99
src/components/search/search-overflow-menu.tsx
··· 1 + import { createMemo } from 'solid-js'; 2 + 3 + import { useModalContext } from '~/globals/modals'; 4 + 5 + import { useSession } from '~/lib/states/session'; 6 + 7 + import AddOutlinedIcon from '~/components/icons-central/add-outline'; 8 + import LinkOutlinedIcon from '~/components/icons-central/link-outline'; 9 + import OpenInNewOutlinedIcon from '~/components/icons-central/open-in-new-outline'; 10 + import ShareOutlinedIcon from '~/components/icons-central/share-outline'; 11 + import TrashOutlinedIcon from '~/components/icons-central/trash-outline'; 12 + import * as Menu from '~/components/menu'; 13 + 14 + export interface SearchOverflowMenuProps { 15 + anchor: HTMLElement; 16 + query: string; 17 + kind: string; 18 + } 19 + 20 + const hasWebShare = typeof navigator.share === 'function'; 21 + 22 + const SearchOverflowMenu = (props: SearchOverflowMenuProps) => { 23 + const { close } = useModalContext(); 24 + const { currentAccount } = useSession(); 25 + 26 + const isSaveable = createMemo(() => { 27 + const kind = props.kind; 28 + return kind === 'latest_posts' || kind === 'top_posts'; 29 + }); 30 + 31 + const saved = createMemo(() => { 32 + if (!currentAccount || !isSaveable()) { 33 + return -1; 34 + } 35 + 36 + const query = props.query; 37 + const kind = props.kind; 38 + 39 + const feeds = currentAccount.preferences.feeds; 40 + const index = feeds.findIndex((f) => f.type === 'search' && f.query === query && f.kind === kind); 41 + 42 + return index; 43 + }); 44 + 45 + return ( 46 + <Menu.Container anchor={props.anchor}> 47 + {currentAccount && isSaveable() && ( 48 + <Menu.Item 49 + icon={saved() === -1 ? AddOutlinedIcon : TrashOutlinedIcon} 50 + label={saved() === -1 ? `Save to my feeds` : `Remove from my feeds`} 51 + onClick={() => { 52 + const query = props.query; 53 + const kind = props.kind; 54 + 55 + const feeds = currentAccount.preferences.feeds; 56 + const index = saved(); 57 + 58 + close(); 59 + 60 + if (index !== -1) { 61 + feeds.splice(index, 1); 62 + } else { 63 + feeds.push({ type: 'search', name: '', query, kind }); 64 + } 65 + }} 66 + /> 67 + )} 68 + 69 + <Menu.Item 70 + icon={hasWebShare ? ShareOutlinedIcon : LinkOutlinedIcon} 71 + label={`${hasWebShare ? `Share` : `Copy`} link`} 72 + onClick={() => { 73 + const url = location.origin + location.pathname + location.search; 74 + 75 + close(); 76 + 77 + if (hasWebShare) { 78 + navigator.share({ url }); 79 + } else { 80 + navigator.clipboard.writeText(url); 81 + } 82 + }} 83 + /> 84 + 85 + <Menu.Item 86 + icon={OpenInNewOutlinedIcon} 87 + label="Open in Bluesky app" 88 + onClick={() => { 89 + const url = `https://bsky.app/search?q=${encodeURIComponent(props.query)}`; 90 + 91 + close(); 92 + window.open(url, '_blank'); 93 + }} 94 + /> 95 + </Menu.Container> 96 + ); 97 + }; 98 + 99 + export default SearchOverflowMenu;
+8 -1
src/lib/preferences/account.ts
··· 31 31 definitions: Record<At.DID, ModerationLabeler>; 32 32 } 33 33 34 - export type SavedFeed = SavedGeneratorFeed | SavedListFeed; 34 + export type SavedFeed = SavedGeneratorFeed | SavedListFeed | SavedSearchFeed; 35 35 36 36 export interface SavedGeneratorFeed { 37 37 readonly type: 'generator'; ··· 43 43 readonly type: 'list'; 44 44 pinned: boolean; 45 45 info: AppBskyGraphDefs.ListView; 46 + } 47 + 48 + export interface SavedSearchFeed { 49 + readonly type: 'search'; 50 + name: string; 51 + query: string; 52 + kind: string; 46 53 } 47 54 48 55 export interface PersistedThreadgate {
+11 -2
src/views/search.tsx
··· 3 3 import { tokenize } from '@atcute/bluesky-search-parser'; 4 4 import { Freeze, ShowFreeze } from '@mary/solid-freeze'; 5 5 6 - import { hasModals } from '~/globals/modals'; 6 + import { hasModals, openModal } from '~/globals/modals'; 7 7 8 8 import { parseEndDate, parseStartDate, splitFilters, stringifySearch } from '~/lib/bsky/search'; 9 9 import { createDerivedSignal } from '~/lib/hooks/derived-signal'; ··· 18 18 import SearchBar from '~/components/main/search-bar'; 19 19 import * as Page from '~/components/page'; 20 20 import { SearchBarProvider } from '~/components/search/context'; 21 + import SearchOverflowMenu from '~/components/search/search-overflow-menu'; 21 22 import SearchSuggestionsView from '~/components/search/search-suggestions-view'; 22 23 import TabBar from '~/components/tab-bar'; 23 24 ··· 93 94 94 95 {!isInputFocused() && ( 95 96 <Page.HeaderAccessory> 96 - <IconButton icon={MoreHorizOutlinedIcon} title="Search actions" /> 97 + <IconButton 98 + icon={MoreHorizOutlinedIcon} 99 + title="Search actions" 100 + onClick={(ev) => { 101 + const anchor = ev.currentTarget; 102 + 103 + openModal(() => <SearchOverflowMenu anchor={anchor} query={params.q} kind={params.t} />); 104 + }} 105 + /> 97 106 </Page.HeaderAccessory> 98 107 )} 99 108 </Page.Header>