From f98fb6e84c120049719ea9fd1b9f6c4383afad0a Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 25 Jun 2024 16:58:15 -0700 Subject: [PATCH] Add new version of note create components --- src/app/App.svelte | 7 +- src/app/Nav.svelte | 20 ++- src/app/shared/NoteCreateContent.svelte | 35 ++++ src/app/shared/NoteCreateControls.svelte | 63 +++++++ src/app/shared/NoteCreateFields.svelte | 52 ++++++ src/app/shared/NoteCreateGroups.svelte | 35 ++++ src/app/shared/NoteCreateKind.svelte | 34 ++++ src/app/shared/NoteCreateOptions.svelte | 19 ++ src/app/shared/NoteCreateRelays.svelte | 37 ++++ src/app/shared/OnboardingTask.svelte | 4 +- src/app/shared/PersonLink.svelte | 3 +- src/app/util/draft.ts | 216 +++++++++++++++++++++++ src/app/util/index.ts | 1 + src/app/views/GroupList.svelte | 4 +- src/app/views/InviteCreate.svelte | 12 +- src/app/views/NoteCreate2.svelte | 56 ++++++ src/engine/commands.ts | 12 +- src/engine/requests/pubkeys.ts | 2 +- src/engine/state.ts | 49 +++-- src/util/misc.ts | 23 +++ 20 files changed, 634 insertions(+), 50 deletions(-) create mode 100644 src/app/shared/NoteCreateContent.svelte create mode 100644 src/app/shared/NoteCreateControls.svelte create mode 100644 src/app/shared/NoteCreateFields.svelte create mode 100644 src/app/shared/NoteCreateGroups.svelte create mode 100644 src/app/shared/NoteCreateKind.svelte create mode 100644 src/app/shared/NoteCreateOptions.svelte create mode 100644 src/app/shared/NoteCreateRelays.svelte create mode 100644 src/app/util/draft.ts create mode 100644 src/app/views/NoteCreate2.svelte diff --git a/src/app/App.svelte b/src/app/App.svelte index 3b6f9142..5f9b8b31 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -203,13 +203,8 @@ router.register("/", import("src/app/views/Home.svelte")) router.register("/notes", import("src/app/views/Home.svelte")) - router.register("/notes/create", import("src/app/views/NoteCreate.svelte"), { + router.register("/notes/create", import("src/app/views/NoteCreate2.svelte"), { requireSigner: true, - serializers: { - pubkey: asPerson, - group: asNaddr("group"), - type: asString("type"), - }, }) router.register("/notes/:entity", import("src/app/views/NoteDetail.svelte"), { serializers: { diff --git a/src/app/Nav.svelte b/src/app/Nav.svelte index a05f0326..10d228cd 100644 --- a/src/app/Nav.svelte +++ b/src/app/Nav.svelte @@ -1,4 +1,5 @@ @@ -69,10 +70,13 @@
-
+ class="absolute right-0 top-10 w-96 rounded opacity-100 shadow-2xl transition-colors"> +
-
+
{#if result.type === "topic"} #{result.topic.name} {:else if result.type === "profile"} diff --git a/src/app/shared/NoteCreateContent.svelte b/src/app/shared/NoteCreateContent.svelte new file mode 100644 index 00000000..f8f1a614 --- /dev/null +++ b/src/app/shared/NoteCreateContent.svelte @@ -0,0 +1,35 @@ + + + + +
+ Todo: new version of compose that combines preview with input (wysiwyg) +
+
+ + Add an image + +
+ + {commaFormat($ctrl.counts.chars)} characters + + + + {commaFormat($ctrl.counts.words)} words + +
+
+
+
diff --git a/src/app/shared/NoteCreateControls.svelte b/src/app/shared/NoteCreateControls.svelte new file mode 100644 index 00000000..d9203a37 --- /dev/null +++ b/src/app/shared/NoteCreateControls.svelte @@ -0,0 +1,63 @@ + + +{#each cards as card (card)} +
+ + + + + + {#if card === "relays"} + + {:else if card === "groups"} + + {:else if card === "options"} + + {/if} + + +
+{/each} + +
+
+ + {$ctrl.draft.relays.length} Relays + + {#if !$env.FORCE_GROUP} + + {$ctrl.draft.groups.length} Groups + + {/if} + + Options + +
+
+ Save as Draft + Send Note +
+
diff --git a/src/app/shared/NoteCreateFields.svelte b/src/app/shared/NoteCreateFields.svelte new file mode 100644 index 00000000..e23409c6 --- /dev/null +++ b/src/app/shared/NoteCreateFields.svelte @@ -0,0 +1,52 @@ + + +{#if $ctrl.draft.kind !== NOTE} + + + +{/if} +{#if $ctrl.draft.kind === CLASSIFIED} + + + + +
+
+ + + + + +
+
+ +
+
+
+{/if} +{#if $ctrl.draft.kind === EVENT_TIME} +
+
+ Start + +
+
+ End + +
+
+{/if} +{#if $ctrl.draft.kind !== NOTE} + + + +{/if} diff --git a/src/app/shared/NoteCreateGroups.svelte b/src/app/shared/NoteCreateGroups.svelte new file mode 100644 index 00000000..502a17ac --- /dev/null +++ b/src/app/shared/NoteCreateGroups.svelte @@ -0,0 +1,35 @@ + + +

Select which groups to publish to

+ + {displayGroupByAddress(option)} + + + {$groupMetaSearch.displayValue(item)} + diff --git a/src/app/shared/NoteCreateKind.svelte b/src/app/shared/NoteCreateKind.svelte new file mode 100644 index 00000000..a58de409 --- /dev/null +++ b/src/app/shared/NoteCreateKind.svelte @@ -0,0 +1,34 @@ + + +
+ Create a + +
+ + {#if $ctrl.draft.kind === NOTE} + Note + {:else if $ctrl.draft.kind === EVENT_TIME} + Event + {:else if $ctrl.draft.kind === CLASSIFIED} + Listing + {/if} + + +
+
+ + ctrl.setKind(NOTE)}>Note + ctrl.setKind(EVENT_TIME)}>Event + ctrl.setKind(CLASSIFIED)}>Listing + +
+
+
diff --git a/src/app/shared/NoteCreateOptions.svelte b/src/app/shared/NoteCreateOptions.svelte new file mode 100644 index 00000000..ad242e2b --- /dev/null +++ b/src/app/shared/NoteCreateOptions.svelte @@ -0,0 +1,19 @@ + + +

Control how your note is presented

+ + + + + +

Enable this to create an anonymous note.

+
diff --git a/src/app/shared/NoteCreateRelays.svelte b/src/app/shared/NoteCreateRelays.svelte new file mode 100644 index 00000000..7be7f487 --- /dev/null +++ b/src/app/shared/NoteCreateRelays.svelte @@ -0,0 +1,37 @@ + + +

Select which relays to publish to

+ + {displayRelayUrl(option)} + + + {$relaySearch.displayValue(item)} + diff --git a/src/app/shared/OnboardingTask.svelte b/src/app/shared/OnboardingTask.svelte index ea45736c..113aff25 100644 --- a/src/app/shared/OnboardingTask.svelte +++ b/src/app/shared/OnboardingTask.svelte @@ -1,5 +1,5 @@ {#if !$session.onboarding_tasks_completed.includes(task)} diff --git a/src/app/shared/PersonLink.svelte b/src/app/shared/PersonLink.svelte index 0747e24f..edb6e586 100644 --- a/src/app/shared/PersonLink.svelte +++ b/src/app/shared/PersonLink.svelte @@ -1,4 +1,5 @@ - + @{$display} diff --git a/src/app/util/draft.ts b/src/app/util/draft.ts new file mode 100644 index 00000000..d11cfe21 --- /dev/null +++ b/src/app/util/draft.ts @@ -0,0 +1,216 @@ +import {v4 as uuid} from "uuid" +import {now, identity} from "@welshman/lib" +import type {EventTemplate} from "@welshman/util" +import {NOTE, CLASSIFIED, EVENT_TIME} from '@welshman/util' +import * as Content from '@welshman/content' +import type {Parsed} from '@welshman/content' +import {currencyOptions} from "src/util/i18n" +import {SelfStore, dateToSeconds} from 'src/util/misc' +import {getClientTags, env, hints} from 'src/engine' + +export enum DraftError { + EmptyContent = "empty_content", + EmptyCurrency = "empty_currency", + EmptyTitle = "empty_title", + EmptyTime = "empty_time", + InvalidPrice = "invalid_price", + HasNsec = "has_nsec", +} + +export type DraftImage = { + url: string, + meta: Record +} + +export type Draft = { + kind: number + groups: string[] + relays: string[] + warning: string + anonymous: boolean + content: Parsed[] + images: DraftImage[] + extra: Record +} + +export const makeDraftNote = (draft: Partial = {}): Draft => ({ + kind: NOTE, + relays: hints.WriteRelays().getUrls(), + groups: [env.get().FORCE_GROUP].filter(identity), + warning: "", + anonymous: false, + content: [], + images: [], + extra: {}, + ...draft, +}) + +export const makeDraftListing = (draft: Partial = {}): Draft => ({ + ...makeDraftNote(), + kind: CLASSIFIED, + extra: { + title: "", + summary: "", + price: "", + currency: currencyOptions.find(o => o.code === "SAT"), + location: "", + }, + ...draft, +}) + +export const makeDraftEvent = (draft: Partial = {}): Draft => ({ + ...makeDraftNote(), + kind: EVENT_TIME, + extra: { + title: "", + location: "", + start: "", + end: "", + }, + ...draft, +}) + +export const validateDraft = ({kind, content, extra}: Draft) => { + const errors = [] + + if (content.length === 0) { + errors.push(DraftError.EmptyContent) + } + + if (kind === EVENT_TIME) { + if (!extra.title) errors.push(DraftError.EmptyTitle) + if (!extra.start || !extra.end) errors.push(DraftError.EmptyTime) + } + + if (kind === CLASSIFIED) { + if (!extra.title) errors.push(DraftError.EmptyTitle) + if (isNaN(parseFloat(extra.price))) DraftError.InvalidPrice + if (!extra.currency) DraftError.EmptyCurrency + } + + return errors +} + +export const createDraft = (draft: Draft): EventTemplate => { + let tags = getClientTags() + + if (draft.warning) { + tags.push(["content-warning", draft.warning]) + } + + for (const {url, meta} of draft.images) { + tags.push(['imeta', ...Object.entries({...meta, url}).map(pair => pair.join(' '))]) + } + + for (const parsed of draft.content) { + if (Content.isTopic(parsed)) { + tags.push(["t", parsed.value]) + } else if (Content.isEvent(parsed)) { + const {id, relays = [], author = ""} = parsed.value + + tags.push(["q", id, relays[0] || "", "mention", author]) + } else if (Content.isProfile(parsed)) { + const {pubkey, relays = []} = parsed.value + + tags.push(["p", pubkey, relays[0] || "", ""]) + } else if (Content.isAddress(parsed)) { + const {kind, pubkey, identifier, relays = []} = parsed.value + const address = [kind, pubkey, identifier].join(":") + + tags.push(["a", address.toString(), relays[0] || ""]) + } + } + + if (draft.kind === EVENT_TIME) { + tags = [ + ...tags, + ["d", uuid()], + ["title", draft.extra.title], + ["summary", draft.extra.summary || ""], + ["location", draft.extra.location || ""], + ["published_at", now().toString()], + ["price", draft.extra.price, draft.extra.currency.code], + ] + } + + if (draft.kind === CLASSIFIED) { + tags = [ + ...tags, + ["d", uuid()], + ["title", draft.extra.title], + ["location", draft.extra.location || ""], + ["start", dateToSeconds(draft.extra.start).toString()], + ["end", dateToSeconds(draft.extra.end).toString()], + ] + } + + return { + kind: draft.kind, + created_at: now(), + content: draft.content.map(p => p.raw).join(''), + tags, + } +} + +export type DraftControllerOpts = { + publish: () => void +} + +export class DraftController extends SelfStore { + nsecWarning = false + skipNsecWarning = false + content = "" + counts = { + words: 0, + chars: 0 + } + + constructor(readonly draft: Draft, readonly opts: DraftControllerOpts) { + super() + + this.content = draft.content.map(p => p.raw).join(''), + this.notify() + } + + set = draft => { + Object.assign(this, draft) + this.notify() + } + + setKind = (kind: number) => { + this.draft.kind = kind + this.notify() + } + + setContent = (content: string) => { + this.content = content + this.counts.chars = content.length || 0 + this.counts.words = content.trim() ? (content.match(/\s+/g)?.length || 0) + 1 : 0 + this.notify() + } + + clearNsecWarning = () => { + this.nsecWarning = false + } + + ignoreNsecWarning = () => { + this.nsecWarning = false + this.skipNsecWarning = true + } + + validate = () => { + const errors = validateDraft(this.draft) + + if (errors.includes(DraftError.HasNsec) && !this.skipNsecWarning) { + this.nsecWarning = Boolean(this.content.match(/\bnsec1.+/)) + } + + return errors + } + + getDraft = () => ({...this.draft, content: Content.parse({content: this.content})}) + + getEvent = () => createDraft(this.getDraft()) + + publish = () => this.opts.publish() +} diff --git a/src/app/util/index.ts b/src/app/util/index.ts index aa5600e4..15266e2f 100644 --- a/src/app/util/index.ts +++ b/src/app/util/index.ts @@ -1,2 +1,3 @@ +export * from "src/app/util/draft" export * from "src/app/util/feeds" export * from "src/app/util/router" diff --git a/src/app/views/GroupList.svelte b/src/app/views/GroupList.svelte index de251ae3..8f046a4b 100644 --- a/src/app/views/GroupList.svelte +++ b/src/app/views/GroupList.svelte @@ -18,7 +18,7 @@ userIsGroupMember, updateCurrentSession, communityListsByAddress, - searchGroupMeta, + groupMetaSearch, groupMeta, } from "src/engine" @@ -34,7 +34,7 @@ let limit = 20 let element = null - $: otherGroupMeta = reject(userIsMember, $searchGroupMeta(q)).slice(0, limit) + $: otherGroupMeta = reject(userIsMember, $groupMetaSearch.searchOptions(q)).slice(0, limit) document.title = "Groups" diff --git a/src/app/views/InviteCreate.svelte b/src/app/views/InviteCreate.svelte index e1e7db45..b0059c32 100644 --- a/src/app/views/InviteCreate.svelte +++ b/src/app/views/InviteCreate.svelte @@ -15,8 +15,8 @@ import GroupCircle from "src/app/shared/GroupCircle.svelte" import PersonSelect from "src/app/shared/PersonSelect.svelte" import {router} from "src/app/util/router" - import {displayRelayUrl, displayGroupMeta} from "src/domain" - import {hints, relaySearch, searchGroupMeta, groupMetaByAddress} from "src/engine" + import {displayRelayUrl} from "src/domain" + import {hints, relaySearch, groupMetaSearch, displayGroupByAddress} from "src/engine" export let initialPubkey = null export let initialGroupAddress = null @@ -70,8 +70,6 @@ groups = toSpliced(groups, i, 1) } - const displayGroupFromAddress = a => displayGroupMeta($groupMetaByAddress.get(a)) - let relayInput, groupInput let sections = [] let pubkeys = [] @@ -185,7 +183,7 @@

{#each groups as group, i (group.address + i)} removeGroup(i)}> - {displayGroupFromAddress(group.address)} + {displayGroupByAddress(group.address)} @@ -194,8 +192,8 @@ getAddress(groupMeta.event)} onChange={groupMeta => groupMeta && addGroup(getAddress(groupMeta.event))}> diff --git a/src/app/views/NoteCreate2.svelte b/src/app/views/NoteCreate2.svelte new file mode 100644 index 00000000..28a41933 --- /dev/null +++ b/src/app/views/NoteCreate2.svelte @@ -0,0 +1,56 @@ + + + + +
+ + + + + +
+
+ +{#if $ctrl.nsecWarning} + +{/if} diff --git a/src/engine/commands.ts b/src/engine/commands.ts index 7d3ead5a..eee7d594 100644 --- a/src/engine/commands.ts +++ b/src/engine/commands.ts @@ -721,7 +721,7 @@ export const updateSingleton = async (kind: number, modifyTags: ModifyTags) => { const template = await encryptable.reconcile(encrypt) - if (window.location.origin.includes('localhost')) { + if (window.location.origin.includes("localhost")) { if (kind === MUTES) { alert("Publishing mutes") console.trace(template) @@ -743,9 +743,9 @@ export const unfollowPerson = (pubkey: string) => { export const followPerson = (pubkey: string) => { if (canSign.get()) { - updateSingleton(FOLLOWS, tags => append(tags, mention(pubkey))) + updateSingleton(FOLLOWS, tags => append(mention(pubkey), tags)) } else { - anonymous.update($a => ({...$a, follows: append($a.follows, mention(pubkey))})) + anonymous.update($a => ({...$a, follows: append(mention(pubkey), $a.follows)})) } } @@ -753,17 +753,17 @@ export const unmutePerson = (pubkey: string) => updateSingleton(MUTES, tags => reject(nthEq(1, pubkey), tags)) export const mutePerson = (pubkey: string) => - updateSingleton(MUTES, tags => append(tags, mention(pubkey))) + updateSingleton(MUTES, tags => append(mention(pubkey), tags)) export const unmuteNote = (id: string) => updateSingleton(MUTES, tags => reject(nthEq(1, id), tags)) -export const muteNote = (id: string) => updateSingleton(MUTES, tags => append(tags, ["e", id])) +export const muteNote = (id: string) => updateSingleton(MUTES, tags => append(["e", id], tags)) export const removeFeedFavorite = (address: string) => updateSingleton(FEEDS, tags => reject(nthEq(1, address), tags)) export const addFeedFavorite = (address: string) => - updateSingleton(FEEDS, tags => append(tags, ["a", address])) + updateSingleton(FEEDS, tags => append(["a", address], tags)) // Relays diff --git a/src/engine/requests/pubkeys.ts b/src/engine/requests/pubkeys.ts index 600d8266..b8644cb7 100644 --- a/src/engine/requests/pubkeys.ts +++ b/src/engine/requests/pubkeys.ts @@ -54,7 +54,7 @@ const getFiltersForKey = (key: string, authors: string[]) => { case "pubkey/relays": return [{authors, kinds: [RELAYS, INBOX_RELAYS]}] case "pubkey/profile": - return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION, COMMUNITIES]}] + return [{authors, kinds: [PROFILE, FOLLOWS, MUTES, HANDLER_INFORMATION, COMMUNITIES]}] case "pubkey/user": return [ {authors, kinds: [PROFILE, RELAYS, MUTES, FOLLOWS, COMMUNITIES, FEEDS]}, diff --git a/src/engine/state.ts b/src/engine/state.ts index 804422bb..bfeafd4d 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -107,6 +107,7 @@ import { } from "src/util/nostr" import logger from "src/util/logger" import type { + GroupMeta, PublishedFeed, PublishedProfile, PublishedListFeed, @@ -141,6 +142,7 @@ import { filterRelaysByNip, displayRelayUrl, readGroupMeta, + displayGroupMeta, } from "src/domain" import type { Channel, @@ -691,7 +693,32 @@ export const groupMetaByAddress = withGetter( export const deriveGroupMeta = (address: string) => derived(groupMetaByAddress, $m => $m.get(address)) -export const searchGroupMeta = derived( +export const displayGroupByAddress = a => displayGroupMeta(groupMetaByAddress.get().get(a)) + +export class GroupSearch extends SearchHelper { + config = { + keys: [{name: "identifier", weight: 0.2}, "name", {name: "about", weight: 0.5}], + threshold: 0.3, + shouldSort: false, + includeScore: true, + } + + getSearch = () => { + const fuse = new Fuse(this.options, this.config) + const sortFn = (r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100) + + return (term: string) => + term + ? sortBy(sortFn, fuse.search(term)).map((r: any) => r.item) + : sortBy(meta => -meta.score, this.options) + } + + getValue = (option: GroupMeta) => getAddress(option.event) + + displayValue = displayGroupByAddress +} + +export const groupMetaSearch = derived( [groupMeta, communityListsByAddress, userFollows], ([$groupMeta, $communityListsByAddress, $userFollows]) => { const options = $groupMeta.map(meta => { @@ -702,19 +729,7 @@ export const searchGroupMeta = derived( return {...meta, score: followedMembers.length} }) - const fuse = new Fuse(options, { - keys: [{name: "identifier", weight: 0.2}, "name", {name: "about", weight: 0.5}], - threshold: 0.3, - shouldSort: false, - includeScore: true, - }) - - const sortFn = (r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100) - - return (term: string) => - term - ? sortBy(sortFn, fuse.search(term)).map((r: any) => r.item.meta) - : sortBy(meta => -meta.score, options) + return new GroupSearch(options) }, ) @@ -2149,19 +2164,19 @@ class IndexedDBAdapter { const removedRecords = prev.filter(r => !currentIds.has(r[key])) if (newRecords.length > 0) { - console.log("putting", name, newRecords.length, current.length) await storage.bulkPut(name, newRecords) } if (removedRecords.length > 0) { - console.trace("deleting", name, removedRecords.length, current.length) + if (name === "repository") { + console.trace("deleting", removedRecords.length, current.length) + } await storage.bulkDelete(name, removedRecords.map(prop(key))) } // If we have much more than our limit, prune our store. This will get persisted // the next time around. if (current.length > limit * 1.5) { - console.log("pruning", name, current.length) set((sort ? sort(current) : current).slice(0, limit)) } diff --git a/src/util/misc.ts b/src/util/misc.ts index d184eecb..e9b18eb4 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -457,3 +457,26 @@ export function withGetter(store: Readable | Writable) { export const throttled = (delay: number, store: Readable) => custom(set => store.subscribe(throttle(delay, set))) + +export class SelfStore { + subs: Sub[] = [] + + notify = () => { + for (const sub of this.subs) { + sub(this) + } + } + + subscribe = (sub: Sub) => { + this.subs.push(sub) + + sub(this) + + return () => { + this.subs.splice( + this.subs.findIndex(s => s === sub), + 1, + ) + } + } +}