diff --git a/apps/desktop/src/routes/home/index.tsx b/apps/desktop/src/routes/home/index.tsx index c9f54bd8..21f35982 100644 --- a/apps/desktop/src/routes/home/index.tsx +++ b/apps/desktop/src/routes/home/index.tsx @@ -11,20 +11,18 @@ import { ArrowLeftIcon, ArrowRightIcon, NewColumnIcon, - PlusIcon, PlusSquareIcon, } from "@lume/icons"; import { IColumn } from "@lume/types"; import { TutorialModal } from "@lume/ui/src/tutorial/modal"; import { COL_TYPES } from "@lume/utils"; import * as Tooltip from "@radix-ui/react-tooltip"; -import { useRef, useState } from "react"; -import { VList, VListHandle } from "virtua"; +import { useState } from "react"; +import { VList } from "virtua"; export function HomeScreen() { - const ref = useRef(null); + const { columns, vlistRef, addColumn } = useColumnContext(); const [selectedIndex, setSelectedIndex] = useState(-1); - const { columns, addColumn } = useColumnContext(); const renderItem = (column: IColumn) => { switch (column.kind) { @@ -52,20 +50,20 @@ export function HomeScreen() { return (
{ - if (!ref.current) return; + if (!vlistRef.current) return; switch (e.code) { case "ArrowUp": case "ArrowLeft": { e.preventDefault(); const prevIndex = Math.max(selectedIndex - 1, 0); setSelectedIndex(prevIndex); - ref.current.scrollToIndex(prevIndex, { + vlistRef.current.scrollToIndex(prevIndex, { align: "center", smooth: true, }); @@ -76,7 +74,7 @@ export function HomeScreen() { e.preventDefault(); const nextIndex = Math.min(selectedIndex + 1, columns.length - 1); setSelectedIndex(nextIndex); - ref.current.scrollToIndex(nextIndex, { + vlistRef.current.scrollToIndex(nextIndex, { align: "center", smooth: true, }); @@ -114,7 +112,7 @@ export function HomeScreen() { onClick={() => { const prevIndex = Math.max(selectedIndex - 1, 0); setSelectedIndex(prevIndex); - ref.current.scrollToIndex(prevIndex, { + vlistRef.current.scrollToIndex(prevIndex, { align: "center", smooth: true, }); @@ -141,7 +139,7 @@ export function HomeScreen() { columns.length - 1, ); setSelectedIndex(nextIndex); - ref.current.scrollToIndex(nextIndex, { + vlistRef.current.scrollToIndex(nextIndex, { align: "center", smooth: true, }); diff --git a/packages/ark/package.json b/packages/ark/package.json index b3bd2497..344c07e7 100644 --- a/packages/ark/package.json +++ b/packages/ark/package.json @@ -37,7 +37,8 @@ "sonner": "^1.3.1", "string-strip-html": "^13.4.5", "tippy.js": "^6.3.7", - "use-context-selector": "^1.4.1" + "use-context-selector": "^1.4.1", + "virtua": "^0.20.5" }, "devDependencies": { "@lume/tailwindcss": "workspace:^", diff --git a/packages/ark/src/ark.ts b/packages/ark/src/ark.ts index 7c04d90c..b283895a 100644 --- a/packages/ark/src/ark.ts +++ b/packages/ark/src/ark.ts @@ -266,6 +266,12 @@ export class Ark { return event; } + public async getEvents(filter: NDKFilter) { + const events = await this.ndk.fetchEvents(filter); + if (!events) return []; + return [...events]; + } + public getEventThread({ content, tags, diff --git a/packages/ark/src/components/column/provider.tsx b/packages/ark/src/components/column/provider.tsx index 115dd017..2136a7bf 100644 --- a/packages/ark/src/components/column/provider.tsx +++ b/packages/ark/src/components/column/provider.tsx @@ -2,17 +2,21 @@ import { useStorage } from "@lume/storage"; import { IColumn } from "@lume/types"; import { COL_TYPES } from "@lume/utils"; import { - ReactNode, + type MutableRefObject, + type ReactNode, createContext, useCallback, useContext, useEffect, + useRef, useState, } from "react"; import { toast } from "sonner"; +import { type VListHandle } from "virtua"; type ColumnContext = { columns: IColumn[]; + vlistRef: MutableRefObject; addColumn: (column: IColumn) => Promise; removeColumn: (id: number) => Promise; moveColumn: (id: number, position: "left" | "right") => void; @@ -24,6 +28,8 @@ const ColumnContext = createContext(null); export function ColumnProvider({ children }: { children: ReactNode }) { const storage = useStorage(); + const vlistRef = useRef(null); + const [columns, setColumns] = useState([ { id: 9999, @@ -112,6 +118,7 @@ export function ColumnProvider({ children }: { children: ReactNode }) {
- +
{isRoot ? "posted:" : "replied:"}
diff --git a/packages/ark/src/components/user/nip05.tsx b/packages/ark/src/components/user/nip05.tsx index 7d56bacf..e3680b89 100644 --- a/packages/ark/src/components/user/nip05.tsx +++ b/packages/ark/src/components/user/nip05.tsx @@ -38,10 +38,12 @@ export function UserNip05({ return (
-

- {user?.nip05?.startsWith("_@") - ? user?.nip05?.replace("_@", "") - : displayNpub(pubkey, 16)} +

+ {!user?.nip05 + ? displayNpub(pubkey, 16) + : user?.nip05?.startsWith("_@") + ? user?.nip05?.replace("_@", "") + : user?.nip05}

{!isLoading && verified ? ( diff --git a/packages/ark/src/components/user/provider.tsx b/packages/ark/src/components/user/provider.tsx index 3bdef978..69b7ef0a 100644 --- a/packages/ark/src/components/user/provider.tsx +++ b/packages/ark/src/components/user/provider.tsx @@ -8,16 +8,20 @@ const UserContext = createContext(null); export function UserProvider({ pubkey, children, -}: { pubkey: string; children: ReactNode }) { + embed, +}: { pubkey: string; children: ReactNode; embed?: string }) { const ark = useArk(); const { data: user } = useQuery({ queryKey: ["user", pubkey], queryFn: async () => { + if (embed) return JSON.parse(embed) as NDKUserProfile; + const profile = await ark.getUserProfile(pubkey); if (!profile) throw new Error( `Cannot get metadata for ${pubkey}, will be retry after 10 seconds`, ); + return profile; }, refetchOnMount: false, diff --git a/packages/ark/src/provider.tsx b/packages/ark/src/provider.tsx index 5b211f9c..0606fb45 100644 --- a/packages/ark/src/provider.tsx +++ b/packages/ark/src/provider.tsx @@ -88,7 +88,6 @@ export const LumeProvider = ({ children }: PropsWithChildren) => { async function initNDK() { const explicitRelayUrls = normalizeRelayUrlSet([ "wss://bostr.nokotaro.com/", - "wss://nostr.mutinywallet.com/", ]); // #TODO: user should config outbox relays @@ -108,10 +107,10 @@ export const LumeProvider = ({ children }: PropsWithChildren) => { explicitRelayUrls, outboxRelayUrls, blacklistRelayUrls, - enableOutboxModel: !storage.settings.lowPower, + enableOutboxModel: false, autoConnectUserRelays: !storage.settings.lowPower, autoFetchUserMutelist: !storage.settings.lowPower, - clientName: "Lume", + // clientName: "Lume", // clientNip89: '', }); diff --git a/packages/icons/index.ts b/packages/icons/index.ts index 2a3b4572..ee2e00a1 100644 --- a/packages/icons/index.ts +++ b/packages/icons/index.ts @@ -110,3 +110,4 @@ export * from "./src/bellFilled"; export * from "./src/foryou"; export * from "./src/editInterest"; export * from "./src/newColumn"; +export * from "./src/searchFilled"; diff --git a/packages/icons/src/searchFilled.tsx b/packages/icons/src/searchFilled.tsx new file mode 100644 index 00000000..67904bee --- /dev/null +++ b/packages/icons/src/searchFilled.tsx @@ -0,0 +1,17 @@ +export function SearchFilledIcon(props: JSX.IntrinsicElements["svg"]) { + return ( + + + + ); +} diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index c6fe2541..720235ca 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -442,14 +442,13 @@ export class LumeStorage { } public async logout() { - const res = await this.#db.execute( + await this.createSetting("nsecbunker", "0"); + await this.#db.execute( "UPDATE accounts SET is_active = '0' WHERE id = $1;", [this.currentUser.id], ); - if (res) { - this.currentUser = null; - this.nwc = null; - } + this.currentUser = null; + this.nwc = null; } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 05829241..e8ec0b63 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ "slate-react": "^0.101.5", "sonner": "^1.3.1", "uqr": "^0.1.2", + "use-debounce": "^10.0.0", "virtua": "^0.20.5" }, "devDependencies": { diff --git a/packages/ui/src/cmdk/command-score.ts b/packages/ui/src/cmdk/command-score.ts new file mode 100644 index 00000000..386eba22 --- /dev/null +++ b/packages/ui/src/cmdk/command-score.ts @@ -0,0 +1,176 @@ +// The scores are arranged so that a continuous match of characters will +// result in a total score of 1. +// +// The best case, this character is a match, and either this is the start +// of the string, or the previous character was also a match. +var SCORE_CONTINUE_MATCH = 1, + // A new match at the start of a word scores better than a new match + // elsewhere as it's more likely that the user will type the starts + // of fragments. + // NOTE: We score word jumps between spaces slightly higher than slashes, brackets + // hyphens, etc. + SCORE_SPACE_WORD_JUMP = 0.9, + SCORE_NON_SPACE_WORD_JUMP = 0.8, + // Any other match isn't ideal, but we include it for completeness. + SCORE_CHARACTER_JUMP = 0.17, + // If the user transposed two letters, it should be significantly penalized. + // + // i.e. "ouch" is more likely than "curtain" when "uc" is typed. + SCORE_TRANSPOSITION = 0.1, + // The goodness of a match should decay slightly with each missing + // character. + // + // i.e. "bad" is more likely than "bard" when "bd" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 100 characters are inserted between matches. + PENALTY_SKIPPED = 0.999, + // The goodness of an exact-case match should be higher than a + // case-insensitive match by a small amount. + // + // i.e. "HTML" is more likely than "haml" when "HM" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 1000 characters are inserted between matches. + PENALTY_CASE_MISMATCH = 0.9999, + // Match higher for letters closer to the beginning of the word + PENALTY_DISTANCE_FROM_START = 0.9, + // If the word has more characters than the user typed, it should + // be penalised slightly. + // + // i.e. "html" is more likely than "html5" if I type "html". + // + // However, it may well be the case that there's a sensible secondary + // ordering (like alphabetical) that it makes sense to rely on when + // there are many prefix matches, so we don't make the penalty increase + // with the number of tokens. + PENALTY_NOT_COMPLETE = 0.99; + +var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, + COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, + IS_SPACE_REGEXP = /[\s-]/, + COUNT_SPACE_REGEXP = /[\s-]/g; + +function commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + stringIndex, + abbreviationIndex, + memoizedResults, +) { + if (abbreviationIndex === abbreviation.length) { + if (stringIndex === string.length) { + return SCORE_CONTINUE_MATCH; + } + return PENALTY_NOT_COMPLETE; + } + + var memoizeKey = `${stringIndex},${abbreviationIndex}`; + if (memoizedResults[memoizeKey] !== undefined) { + return memoizedResults[memoizeKey]; + } + + var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); + var index = lowerString.indexOf(abbreviationChar, stringIndex); + var highScore = 0; + + var score, transposedScore, wordBreaks, spaceBreaks; + + while (index >= 0) { + score = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 1, + memoizedResults, + ); + if (score > highScore) { + if (index === stringIndex) { + score *= SCORE_CONTINUE_MATCH; + } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_NON_SPACE_WORD_JUMP; + wordBreaks = string + .slice(stringIndex, index - 1) + .match(COUNT_GAPS_REGEXP); + if (wordBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); + } + } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_SPACE_WORD_JUMP; + spaceBreaks = string + .slice(stringIndex, index - 1) + .match(COUNT_SPACE_REGEXP); + if (spaceBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); + } + } else { + score *= SCORE_CHARACTER_JUMP; + if (stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, index - stringIndex); + } + } + + if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { + score *= PENALTY_CASE_MISMATCH; + } + } + + if ( + (score < SCORE_TRANSPOSITION && + lowerString.charAt(index - 1) === + lowerAbbreviation.charAt(abbreviationIndex + 1)) || + (lowerAbbreviation.charAt(abbreviationIndex + 1) === + lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 + lowerString.charAt(index - 1) !== + lowerAbbreviation.charAt(abbreviationIndex)) + ) { + transposedScore = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 2, + memoizedResults, + ); + + if (transposedScore * SCORE_TRANSPOSITION > score) { + score = transposedScore * SCORE_TRANSPOSITION; + } + } + + if (score > highScore) { + highScore = score; + } + + index = lowerString.indexOf(abbreviationChar, index + 1); + } + + memoizedResults[memoizeKey] = highScore; + return highScore; +} + +function formatInput(string) { + // convert all valid space characters to space so they match each other + return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " "); +} + +export function commandScore(string: string, abbreviation: string): number { + /* NOTE: + * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() + * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. + */ + return commandScoreInner( + string, + abbreviation, + formatInput(string), + formatInput(abbreviation), + 0, + 0, + {}, + ); +} diff --git a/packages/ui/src/cmdk/index.tsx b/packages/ui/src/cmdk/index.tsx new file mode 100644 index 00000000..95015b35 --- /dev/null +++ b/packages/ui/src/cmdk/index.tsx @@ -0,0 +1,1132 @@ +import * as RadixDialog from "@radix-ui/react-dialog"; +import * as React from "react"; +import { commandScore } from "./command-score"; + +type Children = { children?: React.ReactNode }; +type DivProps = React.HTMLAttributes; + +type LoadingProps = Children & + DivProps & { + /** Estimated progress of loading asynchronous options. */ + progress?: number; + }; +type EmptyProps = Children & DivProps & {}; +type SeparatorProps = DivProps & { + /** Whether this separator should always be rendered. Useful if you disable automatic filtering. */ + alwaysRender?: boolean; +}; +type DialogProps = RadixDialog.DialogProps & + CommandProps & { + /** Provide a className to the Dialog overlay. */ + overlayClassName?: string; + /** Provide a className to the Dialog content. */ + contentClassName?: string; + /** Provide a custom element the Dialog should portal into. */ + container?: HTMLElement; + }; +type ListProps = Children & DivProps & {}; +type ItemProps = Children & + Omit & { + /** Whether this item is currently disabled. */ + disabled?: boolean; + /** Event handler for when this item is selected, either via click or keyboard selection. */ + onSelect?: (value: string) => void; + /** + * A unique value for this item. + * If no value is provided, it will be inferred from `children` or the rendered `textContent`. If your `textContent` changes between renders, you _must_ provide a stable, unique `value`. + */ + value?: string; + /** Whether this item is forcibly rendered regardless of filtering. */ + forceMount?: boolean; + }; +type GroupProps = Children & + Omit & { + /** Optional heading to render for this group. */ + heading?: React.ReactNode; + /** If no heading is provided, you must provide a value that is unique for this group. */ + value?: string; + /** Whether this group is forcibly rendered regardless of filtering. */ + forceMount?: boolean; + }; +type InputProps = Omit< + React.InputHTMLAttributes, + "value" | "onChange" | "type" +> & { + /** + * Optional controlled state for the value of the search input. + */ + value?: string; + /** + * Event handler called when the search value changes. + */ + onValueChange?: (search: string) => void; +}; +type CommandProps = Children & + DivProps & { + /** + * Accessible label for this command menu. Not shown visibly. + */ + label?: string; + /** + * Optionally set to `false` to turn off the automatic filtering and sorting. + * If `false`, you must conditionally render valid items based on the search query yourself. + */ + shouldFilter?: boolean; + /** + * Custom filter function for whether each command menu item should matches the given search query. + * It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely. + * By default, uses the `command-score` library. + */ + filter?: (value: string, search: string) => number; + /** + * Optional default item value when it is initially rendered. + */ + defaultValue?: string; + /** + * Optional controlled state of the selected command menu item. + */ + value?: string; + /** + * Event handler called when the selected item of the menu changes. + */ + onValueChange?: (value: string) => void; + /** + * Optionally set to `true` to turn on looping around when using the arrow keys. + */ + loop?: boolean; + /** + * Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`. + */ + vimBindings?: boolean; + }; + +type Context = { + value: (id: string, value: string) => void; + item: (id: string, groupId: string) => () => void; + group: (id: string) => () => void; + filter: () => boolean; + label: string; + commandRef: React.RefObject; + // Ids + listId: string; + labelId: string; + inputId: string; +}; +type State = { + search: string; + value: string; + filtered: { count: number; items: Map; groups: Set }; +}; +type Store = { + subscribe: (callback: () => void) => () => void; + snapshot: () => State; + setState: ( + key: K, + value: State[K], + opts?: any, + ) => void; + emit: () => void; +}; +type Group = { + id: string; + forceMount?: boolean; +}; + +const LIST_SELECTOR = `[cmdk-list-sizer=""]`; +const GROUP_SELECTOR = `[cmdk-group=""]`; +const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]`; +const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]`; +const ITEM_SELECTOR = `[cmdk-item=""]`; +const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`; +const SELECT_EVENT = `cmdk-item-select`; +const VALUE_ATTR = `data-value`; +const defaultFilter: CommandProps["filter"] = (value, search) => + commandScore(value, search); + +// @ts-ignore +const CommandContext = React.createContext(undefined); +const useCommand = () => React.useContext(CommandContext); +// @ts-ignore +const StoreContext = React.createContext(undefined); +const useStore = () => React.useContext(StoreContext); +// @ts-ignore +const GroupContext = React.createContext(undefined); + +const Command = React.forwardRef( + (props, forwardedRef) => { + const ref = React.useRef(null); + const state = useLazyRef(() => ({ + /** Value of the search query. */ + search: "", + /** Currently selected item value. */ + value: props.value ?? props.defaultValue?.toLowerCase() ?? "", + filtered: { + /** The count of all visible items. */ + count: 0, + /** Map from visible item id to its search score. */ + items: new Map(), + /** Set of groups with at least one visible item. */ + groups: new Set(), + }, + })); + const allItems = useLazyRef>(() => new Set()); // [...itemIds] + const allGroups = useLazyRef>>(() => new Map()); // groupId → [...itemIds] + const ids = useLazyRef>(() => new Map()); // id → value + const listeners = useLazyRef void>>(() => new Set()); // [...rerenders] + const propsRef = useAsRef(props); + const { + label, + children, + value, + onValueChange, + filter, + shouldFilter, + vimBindings = true, + ...etc + } = props; + + const listId = React.useId(); + const labelId = React.useId(); + const inputId = React.useId(); + + const schedule = useScheduleLayoutEffect(); + + /** Controlled mode `value` handling. */ + useLayoutEffect(() => { + if (value !== undefined) { + const v = value.trim().toLowerCase(); + state.current.value = v; + schedule(6, scrollSelectedIntoView); + store.emit(); + } + }, [value]); + + const store: Store = React.useMemo(() => { + return { + subscribe: (cb) => { + listeners.current.add(cb); + return () => listeners.current.delete(cb); + }, + snapshot: () => { + return state.current; + }, + setState: (key, value, opts) => { + if (Object.is(state.current[key], value)) return; + state.current[key] = value; + + if (key === "search") { + // Filter synchronously before emitting back to children + filterItems(); + sort(); + schedule(1, selectFirstItem); + } else if (key === "value") { + if (propsRef.current?.value !== undefined) { + // If controlled, just call the callback instead of updating state internally + const newValue = (value ?? "") as string; + propsRef.current.onValueChange?.(newValue); + return; + // opts is a boolean referring to whether it should NOT be scrolled into view + } else if (!opts) { + // Scroll the selected item into view + schedule(5, scrollSelectedIntoView); + } + } + + // Notify subscribers that state has changed + store.emit(); + }, + emit: () => { + listeners.current.forEach((l) => l()); + }, + }; + }, []); + + const context: Context = React.useMemo( + () => ({ + // Keep id → value mapping up-to-date + value: (id, value) => { + if (value !== ids.current.get(id)) { + ids.current.set(id, value); + state.current.filtered.items.set(id, score(value)); + schedule(2, () => { + sort(); + store.emit(); + }); + } + }, + // Track item lifecycle (mount, unmount) + item: (id, groupId) => { + allItems.current.add(id); + + // Track this item within the group + if (groupId) { + if (!allGroups.current.has(groupId)) { + allGroups.current.set(groupId, new Set([id])); + } else { + allGroups.current.get(groupId).add(id); + } + } + + // Batch this, multiple items can mount in one pass + // and we should not be filtering/sorting/emitting each time + schedule(3, () => { + filterItems(); + sort(); + + // Could be initial mount, select the first item if none already selected + if (!state.current.value) { + selectFirstItem(); + } + + store.emit(); + }); + + return () => { + ids.current.delete(id); + allItems.current.delete(id); + state.current.filtered.items.delete(id); + const selectedItem = getSelectedItem(); + + // Batch this, multiple items could be removed in one pass + schedule(4, () => { + filterItems(); + + // The item removed have been the selected one, + // so selection should be moved to the first + if (selectedItem?.getAttribute("id") === id) selectFirstItem(); + + store.emit(); + }); + }; + }, + // Track group lifecycle (mount, unmount) + group: (id) => { + if (!allGroups.current.has(id)) { + allGroups.current.set(id, new Set()); + } + + return () => { + ids.current.delete(id); + allGroups.current.delete(id); + }; + }, + filter: () => { + return propsRef.current.shouldFilter; + }, + label: label || props["aria-label"], + commandRef: ref, + listId, + inputId, + labelId, + }), + [], + ); + + function score(value: string) { + const filter = propsRef.current?.filter ?? defaultFilter; + return value ? filter(value, state.current.search) : 0; + } + + /** Sorts items by score, and groups by highest item score. */ + function sort() { + if ( + !ref.current || + !state.current.search || + // Explicitly false, because true | undefined is the default + propsRef.current.shouldFilter === false + ) { + return; + } + + const scores = state.current.filtered.items; + + // Sort the groups + const groups: [string, number][] = []; + state.current.filtered.groups.forEach((value) => { + const items = allGroups.current.get(value); + + // Get the maximum score of the group's items + let max = 0; + items.forEach((item) => { + const score = scores.get(item); + max = Math.max(score, max); + }); + + groups.push([value, max]); + }); + + // Sort items within groups to bottom + // Sort items outside of groups + // Sort groups to bottom (pushes all non-grouped items to the top) + const list = ref.current.querySelector(LIST_SELECTOR); + + // Sort the items + getValidItems() + .sort((a, b) => { + const valueA = a.getAttribute(VALUE_ATTR); + const valueB = b.getAttribute(VALUE_ATTR); + return (scores.get(valueB) ?? 0) - (scores.get(valueA) ?? 0); + }) + .forEach((item) => { + const group = item.closest(GROUP_ITEMS_SELECTOR); + + if (group) { + group.appendChild( + item.parentElement === group + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), + ); + } else { + list.appendChild( + item.parentElement === list + ? item + : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), + ); + } + }); + + groups + .sort((a, b) => b[1] - a[1]) + .forEach((group) => { + const element = ref.current.querySelector( + `${GROUP_SELECTOR}[${VALUE_ATTR}="${group[0]}"]`, + ); + element?.parentElement.appendChild(element); + }); + } + + function selectFirstItem() { + const item = getValidItems().find((item) => !item.ariaDisabled); + const value = item?.getAttribute(VALUE_ATTR); + store.setState("value", value || undefined); + } + + /** Filters the current items. */ + function filterItems() { + if ( + !state.current.search || + // Explicitly false, because true | undefined is the default + propsRef.current.shouldFilter === false + ) { + state.current.filtered.count = allItems.current.size; + // Do nothing, each item will know to show itself because search is empty + return; + } + + // Reset the groups + state.current.filtered.groups = new Set(); + let itemCount = 0; + + // Check which items should be included + for (const id of allItems.current) { + const value = ids.current.get(id); + const rank = score(value); + state.current.filtered.items.set(id, rank); + if (rank > 0) itemCount++; + } + + // Check which groups have at least 1 item shown + for (const [groupId, group] of allGroups.current) { + for (const itemId of group) { + if (state.current.filtered.items.get(itemId) > 0) { + state.current.filtered.groups.add(groupId); + break; + } + } + } + + state.current.filtered.count = itemCount; + } + + function scrollSelectedIntoView() { + const item = getSelectedItem(); + + if (item) { + if (item.parentElement?.firstChild === item) { + // First item in Group, ensure heading is in view + item + .closest(GROUP_SELECTOR) + ?.querySelector(GROUP_HEADING_SELECTOR) + ?.scrollIntoView({ block: "nearest" }); + } + + // Ensure the item is always in view + item.scrollIntoView({ block: "nearest" }); + } + } + + /** Getters */ + + function getSelectedItem() { + return ref.current?.querySelector( + `${ITEM_SELECTOR}[aria-selected="true"]`, + ); + } + + function getValidItems() { + return Array.from(ref.current.querySelectorAll(VALID_ITEM_SELECTOR)); + } + + /** Setters */ + + function updateSelectedToIndex(index: number) { + const items = getValidItems(); + const item = items[index]; + if (item) store.setState("value", item.getAttribute(VALUE_ATTR)); + } + + function updateSelectedByChange(change: 1 | -1) { + const selected = getSelectedItem(); + const items = getValidItems(); + const index = items.findIndex((item) => item === selected); + + // Get item at this index + let newSelected = items[index + change]; + + if (propsRef.current?.loop) { + newSelected = + index + change < 0 + ? items[items.length - 1] + : index + change === items.length + ? items[0] + : items[index + change]; + } + + if (newSelected) + store.setState("value", newSelected.getAttribute(VALUE_ATTR)); + } + + function updateSelectedToGroup(change: 1 | -1) { + const selected = getSelectedItem(); + let group = selected?.closest(GROUP_SELECTOR); + let item: HTMLElement; + + while (group && !item) { + group = + change > 0 + ? findNextSibling(group, GROUP_SELECTOR) + : findPreviousSibling(group, GROUP_SELECTOR); + item = group?.querySelector(VALID_ITEM_SELECTOR); + } + + if (item) { + store.setState("value", item.getAttribute(VALUE_ATTR)); + } else { + updateSelectedByChange(change); + } + } + + const last = () => updateSelectedToIndex(getValidItems().length - 1); + + const next = (e: React.KeyboardEvent) => { + e.preventDefault(); + + if (e.metaKey) { + // Last item + last(); + } else if (e.altKey) { + // Next group + updateSelectedToGroup(1); + } else { + // Next item + updateSelectedByChange(1); + } + }; + + const prev = (e: React.KeyboardEvent) => { + e.preventDefault(); + + if (e.metaKey) { + // First item + updateSelectedToIndex(0); + } else if (e.altKey) { + // Previous group + updateSelectedToGroup(-1); + } else { + // Previous item + updateSelectedByChange(-1); + } + }; + + return ( +
{ + etc.onKeyDown?.(e); + + if (!e.defaultPrevented) { + switch (e.key) { + case "n": + case "j": { + // vim keybind down + if (vimBindings && e.ctrlKey) { + next(e); + } + break; + } + case "ArrowDown": { + next(e); + break; + } + case "p": + case "k": { + // vim keybind up + if (vimBindings && e.ctrlKey) { + prev(e); + } + break; + } + case "ArrowUp": { + prev(e); + break; + } + case "Home": { + // First item + e.preventDefault(); + updateSelectedToIndex(0); + break; + } + case "End": { + // Last item + e.preventDefault(); + last(); + break; + } + case "Enter": { + // Check if IME composition is finished before triggering onSelect + // This prevents unwanted triggering while user is still inputting text with IME + if (!e.nativeEvent.isComposing) { + // Trigger item onSelect + e.preventDefault(); + const item = getSelectedItem(); + if (item) { + const event = new Event(SELECT_EVENT); + item.dispatchEvent(event); + } + } + } + } + } + }} + > + + + + {children} + + +
+ ); + }, +); + +/** + * Command menu item. Becomes active on pointer enter or through keyboard navigation. + * Preferably pass a `value`, otherwise the value will be inferred from `children` or + * the rendered item's `textContent`. + */ +const Item = React.forwardRef( + (props, forwardedRef) => { + const id = React.useId(); + const ref = React.useRef(null); + const groupContext = React.useContext(GroupContext); + const context = useCommand(); + const propsRef = useAsRef(props); + const forceMount = propsRef.current?.forceMount ?? groupContext?.forceMount; + + useLayoutEffect(() => { + return context.item(id, groupContext?.id); + }, []); + + const value = useValue(id, ref, [props.value, props.children, ref]); + + const store = useStore(); + const selected = useCmdk( + (state) => state.value && state.value === value.current, + ); + const render = useCmdk((state) => + forceMount + ? true + : context.filter() === false + ? true + : !state.search + ? true + : state.filtered.items.get(id) > 0, + ); + + React.useEffect(() => { + const element = ref.current; + if (!element || props.disabled) return; + element.addEventListener(SELECT_EVENT, onSelect); + return () => element.removeEventListener(SELECT_EVENT, onSelect); + }, [render, props.onSelect, props.disabled]); + + function onSelect() { + select(); + propsRef.current.onSelect?.(value.current); + } + + function select() { + store.setState("value", value.current, true); + } + + if (!render) return null; + + const { disabled, value: _, onSelect: __, ...etc } = props; + + return ( +
+ {props.children} +
+ ); + }, +); + +/** + * Group command menu items together with a heading. + * Grouped items are always shown together. + */ +const Group = React.forwardRef( + (props, forwardedRef) => { + const { heading, children, forceMount, ...etc } = props; + const id = React.useId(); + const ref = React.useRef(null); + const headingRef = React.useRef(null); + const headingId = React.useId(); + const context = useCommand(); + const render = useCmdk((state) => + forceMount + ? true + : context.filter() === false + ? true + : !state.search + ? true + : state.filtered.groups.has(id), + ); + + useLayoutEffect(() => { + return context.group(id); + }, []); + + useValue(id, ref, [props.value, props.heading, headingRef]); + + const contextValue = React.useMemo( + () => ({ id, forceMount }), + [forceMount], + ); + const inner = ( + + {children} + + ); + + return ( + + ); + }, +); + +/** + * A visual and semantic separator between items or groups. + * Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. + */ +const Separator = React.forwardRef( + (props, forwardedRef) => { + const { alwaysRender, ...etc } = props; + const ref = React.useRef(null); + const render = useCmdk((state) => !state.search); + + if (!alwaysRender && !render) return null; + return ( +
+ ); + }, +); + +/** + * Command menu input. + * All props are forwarded to the underyling `input` element. + */ +const Input = React.forwardRef( + (props, forwardedRef) => { + const { onValueChange, ...etc } = props; + const isControlled = props.value != null; + const store = useStore(); + const search = useCmdk((state) => state.search); + const value = useCmdk((state) => state.value); + const context = useCommand(); + + const selectedItemId = React.useMemo(() => { + const item = context.commandRef.current?.querySelector( + `${ITEM_SELECTOR}[${VALUE_ATTR}="${value}"]`, + ); + return item?.getAttribute("id"); + }, [value, context.commandRef]); + + React.useEffect(() => { + if (props.value != null) { + store.setState("search", props.value); + } + }, [props.value]); + + return ( + { + if (!isControlled) { + store.setState("search", e.target.value); + } + + onValueChange?.(e.target.value); + }} + /> + ); + }, +); + +/** + * Contains `Item`, `Group`, and `Separator`. + * Use the `--cmdk-list-height` CSS variable to animate height based on the number of results. + */ +const List = React.forwardRef( + (props, forwardedRef) => { + const { children, ...etc } = props; + const ref = React.useRef(null); + const height = React.useRef(null); + const context = useCommand(); + + React.useEffect(() => { + if (height.current && ref.current) { + const el = height.current; + const wrapper = ref.current; + let animationFrame; + const observer = new ResizeObserver(() => { + animationFrame = requestAnimationFrame(() => { + const height = el.offsetHeight; + wrapper.style.setProperty( + `--cmdk-list-height`, + height.toFixed(1) + "px", + ); + }); + }); + observer.observe(el); + return () => { + cancelAnimationFrame(animationFrame); + observer.unobserve(el); + }; + } + }, []); + + return ( +
+
+ {children} +
+
+ ); + }, +); + +/** + * Renders the command menu in a Radix Dialog. + */ +const Dialog = React.forwardRef( + (props, forwardedRef) => { + const { + open, + onOpenChange, + overlayClassName, + contentClassName, + container, + ...etc + } = props; + return ( + + + + + + + + + ); + }, +); + +/** + * Automatically renders when there are no results for the search query. + */ +const Empty = React.forwardRef( + (props, forwardedRef) => { + const isFirstRender = React.useRef(true); + const render = useCmdk((state) => state.filtered.count === 0); + + React.useEffect(() => { + isFirstRender.current = false; + }, []); + + if (isFirstRender.current || !render) return null; + return ( +
+ ); + }, +); + +/** + * You should conditionally render this with `progress` while loading asynchronous items. + */ +const Loading = React.forwardRef( + (props, forwardedRef) => { + const { progress, children, ...etc } = props; + + return ( +
+
{children}
+
+ ); + }, +); + +const pkg = Object.assign(Command, { + List, + Item, + Input, + Group, + Separator, + Dialog, + Empty, + Loading, +}); + +export { useCmdk as useCommandState }; +export { pkg as Command }; + +export { Command as CommandRoot }; +export { List as CommandList }; +export { Item as CommandItem }; +export { Input as CommandInput }; +export { Group as CommandGroup }; +export { Separator as CommandSeparator }; +export { Dialog as CommandDialog }; +export { Empty as CommandEmpty }; +export { Loading as CommandLoading }; + +/** + * + * + * Helpers + * + * + */ + +function findNextSibling(el: Element, selector: string) { + let sibling = el.nextElementSibling; + + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.nextElementSibling; + } +} + +function findPreviousSibling(el: Element, selector: string) { + let sibling = el.previousElementSibling; + + while (sibling) { + if (sibling.matches(selector)) return sibling; + sibling = sibling.previousElementSibling; + } +} + +function useAsRef(data: T) { + const ref = React.useRef(data); + + useLayoutEffect(() => { + ref.current = data; + }); + + return ref; +} + +const useLayoutEffect = + typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; + +function useLazyRef(fn: () => T) { + const ref = React.useRef(); + + if (ref.current === undefined) { + ref.current = fn(); + } + + return ref as React.MutableRefObject; +} + +// ESM is still a nightmare with Next.js so I'm just gonna copy the package code in +// https://github.com/gregberge/react-merge-refs +// Copyright (c) 2020 Greg Bergé +function mergeRefs( + refs: Array | React.LegacyRef>, +): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else if (ref != null) { + (ref as React.MutableRefObject).current = value; + } + }); + }; +} + +/** Run a selector against the store state. */ +function useCmdk(selector: (state: State) => T) { + const store = useStore(); + const cb = () => selector(store.snapshot()); + return React.useSyncExternalStore(store.subscribe, cb, cb); +} + +function useValue( + id: string, + ref: React.RefObject, + deps: (string | React.ReactNode | React.RefObject)[], +) { + const valueRef = React.useRef(); + const context = useCommand(); + + useLayoutEffect(() => { + const value = (() => { + for (const part of deps) { + if (typeof part === "string") { + return part.trim().toLowerCase(); + } + + if (typeof part === "object" && "current" in part) { + if (part.current) { + return part.current.textContent?.trim().toLowerCase(); + } + return valueRef.current; + } + } + })(); + + context.value(id, value); + ref.current?.setAttribute(VALUE_ATTR, value); + valueRef.current = value; + }); + + return valueRef; +} + +/** Imperatively run a function on the next layout effect cycle. */ +const useScheduleLayoutEffect = () => { + const [s, ss] = React.useState(); + const fns = useLazyRef(() => new Map void>()); + + useLayoutEffect(() => { + fns.current.forEach((f) => f()); + fns.current = new Map(); + }, [s]); + + return (id: string | number, cb: () => void) => { + fns.current.set(id, cb); + ss({}); + }; +}; + +const srOnlyStyles = { + position: "absolute", + width: "1px", + height: "1px", + padding: "0", + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + borderWidth: "0", +} as const; diff --git a/packages/ui/src/layouts/app.tsx b/packages/ui/src/layouts/app.tsx index 7d584254..3c099cb5 100644 --- a/packages/ui/src/layouts/app.tsx +++ b/packages/ui/src/layouts/app.tsx @@ -3,6 +3,7 @@ import { type Platform } from "@tauri-apps/plugin-os"; import { Outlet } from "react-router-dom"; import { Editor } from "../editor/column"; import { Navigation } from "../navigation"; +import { SearchDialog } from "../search/dialog"; import { WindowTitleBar } from "../titlebar"; export function AppLayout({ platform }: { platform: Platform }) { @@ -21,6 +22,7 @@ export function AppLayout({ platform }: { platform: Platform }) {
+
diff --git a/packages/ui/src/navigation.tsx b/packages/ui/src/navigation.tsx index 982f0300..86e81401 100644 --- a/packages/ui/src/navigation.tsx +++ b/packages/ui/src/navigation.tsx @@ -7,10 +7,12 @@ import { DepotIcon, HomeFilledIcon, HomeIcon, + SearchFilledIcon, + SearchIcon, SettingsFilledIcon, SettingsIcon, } from "@lume/icons"; -import { cn, editorAtom } from "@lume/utils"; +import { cn, editorAtom, searchAtom } from "@lume/utils"; import { useAtom } from "jotai"; import { useHotkeys } from "react-hotkeys-hook"; import { NavLink } from "react-router-dom"; @@ -19,6 +21,9 @@ import { UnreadActivity } from "./unread"; export function Navigation() { const [isEditorOpen, setIsEditorOpen] = useAtom(editorAtom); + const [search, setSearch] = useAtom(searchAtom); + + // shortcut for editor useHotkeys("meta+n", () => setIsEditorOpen((state) => !state), []); return ( @@ -117,7 +122,27 @@ export function Navigation() {
-
+
+ ([]); + const [search, setSearch] = useState(""); + const [value] = useDebounce(search, 1200); + + const ark = useArk(); + const { vlistRef, columns, addColumn } = useColumnContext(); + + const searchEvents = async () => { + if (!value.length) return; + + // start loading + setLoading(true); + + // search events, require nostr.band relay + const events = await ark.getEvents({ + kinds: [NDKKind.Text, NDKKind.Metadata], + search: value, + limit: 20, + }); + + // update state + setLoading(false); + setEvents(events); + }; + + const selectEvent = (kind: NDKKind, value: string) => { + if (!value.length) return; + + if (kind === NDKKind.Metadata) { + // add new column + addColumn({ + kind: COL_TYPES.user, + title: "User", + content: value, + }); + } else { + // add new column + addColumn({ + kind: COL_TYPES.thread, + title: "", + content: value, + }); + } + + // update state + setOpen(false); + vlistRef?.current.scrollToIndex(columns.length); + }; + + useEffect(() => { + searchEvents(); + }, [value]); + + // Toggle the menu when ⌘K is pressed + useEffect(() => { + const down = (e) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + return ( + +
+ +
+ + {loading ? ( + + + + ) : !events.length ? ( + + No results found. + + ) : ( + <> + + {events + .filter((ev) => ev.kind === NDKKind.Metadata) + .map((event) => ( + selectEvent(event.kind, value)} + className="py-3 px-3 bg-neutral-50 dark:bg-neutral-950 rounded-xl my-3 focus:ring-1 focus:ring-blue-500" + > + + +
+ +
+ + +
+
+ +
+
+
+ ))} +
+ + {events + .filter((ev) => ev.kind === NDKKind.Text) + .map((event) => ( + selectEvent(event.kind, value)} + className="py-3 px-3 bg-neutral-50 dark:bg-neutral-950 rounded-xl my-3" + > + + + +
+ {event.content} +
+
+
+
+ ))} +
+ + )} +
+
+ ); +} diff --git a/packages/utils/src/state.ts b/packages/utils/src/state.ts index c349789e..682b42a5 100644 --- a/packages/utils/src/state.ts +++ b/packages/utils/src/state.ts @@ -22,3 +22,6 @@ export const activityUnreadAtom = atom(0); // Tutorial export const tutorialAtom = atomWithStorage("tutorial", true); + +// Search +export const searchAtom = atom(false); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac50f09f..e3720798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,9 @@ importers: use-context-selector: specifier: ^1.4.1 version: 1.4.1(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0) + virtua: + specifier: ^0.20.5 + version: 0.20.5(react-dom@18.2.0)(react@18.2.0) devDependencies: '@lume/tailwindcss': specifier: workspace:^ @@ -1051,6 +1054,9 @@ importers: uqr: specifier: ^0.1.2 version: 0.1.2 + use-debounce: + specifier: ^10.0.0 + version: 10.0.0(react@18.2.0) virtua: specifier: ^0.20.5 version: 0.20.5(react-dom@18.2.0)(react@18.2.0) @@ -8373,6 +8379,15 @@ packages: scheduler: 0.23.0 dev: false + /use-debounce@10.0.0(react@18.2.0): + resolution: {integrity: sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + dev: false + /use-sidecar@1.1.2(@types/react@18.2.48)(react@18.2.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'}