Add classified listings
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeClassifiedPath, makeSpacePath} from "@app/util/routes"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
showRoom?: boolean
|
||||
showActivity?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showRoom, showActivity}: Props = $props()
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
const path = makeClassifiedPath(url, event.id)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
const createReaction = async (template: EventContent) =>
|
||||
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-wrap justify-end gap-2">
|
||||
{#if h && showRoom}
|
||||
<Link href={makeSpacePath(url, h)} class="btn btn-neutral btn-xs rounded-full">
|
||||
Posted in #<RoomName {h} {url} />
|
||||
</Link>
|
||||
{/if}
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
|
||||
<ThunkStatusOrDeleted {event} />
|
||||
{#if showActivity}
|
||||
<EventActivity {url} {path} {event} />
|
||||
{/if}
|
||||
<EventActions {url} {event} noun="Listing" />
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import {writable} from "svelte/store"
|
||||
import {makeEvent, CLASSIFIED} from "@welshman/util"
|
||||
import {publishThunk} from "@welshman/app"
|
||||
import {isMobile, preventDefault} from "@lib/html"
|
||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {PROTECTED} from "@app/core/state"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {canEnforceNip70} from "@app/core/commands"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h?: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const uploading = writable(false)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
|
||||
|
||||
const submit = async () => {
|
||||
if ($uploading) return
|
||||
|
||||
if (!title) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide a title for your listing.",
|
||||
})
|
||||
}
|
||||
|
||||
const ed = await editor
|
||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
||||
|
||||
if (!content.trim()) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "Please provide a message for your listing.",
|
||||
})
|
||||
}
|
||||
|
||||
const tags = [...ed.storage.nostr.getEditorTags(), ["title", title]]
|
||||
|
||||
if (await shouldProtect) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
if (h) {
|
||||
tags.push(["h", h])
|
||||
}
|
||||
|
||||
publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(CLASSIFIED, {content, tags}),
|
||||
})
|
||||
|
||||
history.back()
|
||||
}
|
||||
|
||||
const editor = makeEditor({url, submit, uploading, placeholder: "What's on your mind?"})
|
||||
|
||||
let title: string = $state("")
|
||||
</script>
|
||||
|
||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Create a Classified Listing</ModalTitle>
|
||||
<ModalSubtitle>Advertise a job, sale, or need.</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="col-8 relative">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Title*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input input-bordered flex w-full items-center gap-2">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus={!isMobile}
|
||||
bind:value={title}
|
||||
class="grow"
|
||||
type="text"
|
||||
placeholder="What is this listing for?" />
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Description*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="note-editor flex-grow overflow-hidden">
|
||||
<EditorContent {editor} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Price*</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
todo: value and search select inline
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Images</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
todo: attach multiple images
|
||||
{/snippet}
|
||||
</Field>
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="tooltip tooltip-left absolute bottom-1 right-2"
|
||||
onclick={selectFiles}>
|
||||
{#if $uploading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Icon icon={Paperclip} size={3} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
<Button type="submit" class="btn btn-primary">Create Listing</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import {formatTimestamp} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
|
||||
import RoomLink from "@app/components/RoomLink.svelte"
|
||||
import {makeClassifiedPath} from "@app/util/routes"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
|
||||
const title = getTagValue("title", event.tags)
|
||||
const h = getTagValue("h", event.tags)
|
||||
</script>
|
||||
|
||||
<Link
|
||||
class="col-2 card2 bg-alt w-full cursor-pointer shadow-xl"
|
||||
href={makeClassifiedPath(url, event.id)}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<p class="text-xl">{title}</p>
|
||||
<p class="text-sm opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 h-0 text-xs opacity-75">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</p>
|
||||
{/if}
|
||||
<Content {event} {url} expandMode="inline" />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
||||
Posted by
|
||||
<ProfileLink pubkey={event.pubkey} {url} />
|
||||
{#if h}
|
||||
in <RoomLink {url} {h} />
|
||||
{/if}
|
||||
</span>
|
||||
<ClassifiedActions showActivity {url} {event} />
|
||||
</div>
|
||||
</Link>
|
||||
@@ -5,19 +5,20 @@
|
||||
import EventActivity from "@app/components/EventActivity.svelte"
|
||||
import EventActions from "@app/components/EventActions.svelte"
|
||||
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
|
||||
import {makeThreadPath} from "@app/util/routes"
|
||||
import {makeSpacePath} from "@app/util/routes"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
event: any
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
segment: string
|
||||
showActivity?: boolean
|
||||
}
|
||||
|
||||
const {url, event, showActivity = false}: Props = $props()
|
||||
const {url, event, segment, showActivity = false}: Props = $props()
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
const path = makeThreadPath(url, event.id)
|
||||
const path = makeSpacePath(url, segment, event.id)
|
||||
|
||||
const deleteReaction = async (event: TrustedEvent) =>
|
||||
publishDelete({relays: [url], event, protect: await shouldProtect})
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
|
||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +26,8 @@
|
||||
|
||||
const createThread = () => pushModal(ThreadCreate, {url, h})
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||
|
||||
let ul: Element
|
||||
|
||||
onMount(() => {
|
||||
@@ -35,13 +39,19 @@
|
||||
<li>
|
||||
<Button onclick={createGoal}>
|
||||
<Icon size={4} icon={StarFallMinimalistic} />
|
||||
Create Funding Goal
|
||||
Funding Goal
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createCalendarEvent}>
|
||||
<Icon size={4} icon={CalendarMinimalistic} />
|
||||
Create Calendar Event
|
||||
Calendar Event
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onclick={createClassified}>
|
||||
<Icon size={4} icon={CaseMinimalistic} />
|
||||
Classified Listing
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import {clip} from "@app/util/toast"
|
||||
@@ -106,6 +107,8 @@
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
<Button class="btn btn-primary" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="btn btn-primary flex-grow" onclick={() => history.back()}>Got it</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
@@ -13,6 +14,8 @@
|
||||
<NoteContentEventTime {...props} />
|
||||
{:else if props.event.kind === THREAD}
|
||||
<NoteContentThread {...props} />
|
||||
{:else if props.event.kind === CLASSIFIED}
|
||||
<NoteContentClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentGoal {...props} />
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
|
||||
const props: ComponentProps<typeof Content> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if title}
|
||||
<p class="text-xl">{title}</p>
|
||||
{/if}
|
||||
{#if props.event.content}
|
||||
<Content {...props} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
|
||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
@@ -14,6 +15,8 @@
|
||||
<NoteContentMinimalEventTime {...props} />
|
||||
{:else if props.event.kind === THREAD}
|
||||
<NoteContentMinimalThread {...props} />
|
||||
{:else if props.event.kind === CLASSIFIED}
|
||||
<NoteContentMinimalClassified {...props} />
|
||||
{:else if props.event.kind === ZAP_GOAL}
|
||||
<NoteContentMinimalGoal {...props} />
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type {ComponentProps} from "svelte"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||
|
||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||
|
||||
const title = getTagValue("title", props.event.tags)
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<span class="text-sm">{title}</span>
|
||||
{/if}
|
||||
{#if props.event.content}
|
||||
<ContentMinimal {...props} />
|
||||
{/if}
|
||||
@@ -131,8 +131,7 @@
|
||||
<form class="relative flex gap-2 p-2" onsubmit={preventDefault(submit)}>
|
||||
<div class="join">
|
||||
<Button
|
||||
data-tip="Add an image"
|
||||
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
class="center join-item h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
disabled={$uploading}
|
||||
onclick={uploadFiles}>
|
||||
{#if $uploading}
|
||||
@@ -147,8 +146,7 @@
|
||||
props={{url, h, onClick: hidePopover}}
|
||||
params={{trigger: "manual", interactive: true}}>
|
||||
<Button
|
||||
data-tip="More options"
|
||||
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
class="center join-item h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
|
||||
disabled={$uploading}
|
||||
onclick={showPopover}>
|
||||
<Icon icon={WidgetAdd} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, REPORT} from "@welshman/util"
|
||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, REPORT} from "@welshman/util"
|
||||
import {deriveRelay, pubkey} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||
@@ -16,6 +16,7 @@
|
||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
|
||||
@@ -62,6 +63,7 @@
|
||||
const chatPath = makeSpacePath(url, "chat")
|
||||
const goalsPath = makeSpacePath(url, "goals")
|
||||
const threadsPath = makeSpacePath(url, "threads")
|
||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
@@ -228,6 +230,14 @@
|
||||
<Icon icon={NotesMinimalistic} /> Threads
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(CLASSIFIED)}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
href={classifiedsPath}
|
||||
notification={$notifications.has(classifiedsPath)}>
|
||||
<Icon icon={CaseMinimalistic} /> Classifieds
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
{#if $spaceKinds.has(EVENT_TIME)}
|
||||
<SecondaryNavItem
|
||||
{replaceState}
|
||||
|
||||
+14
-2
@@ -80,6 +80,7 @@ import {
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOMS,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
WRAP,
|
||||
PROFILE,
|
||||
ZAP_GOAL,
|
||||
@@ -169,7 +170,18 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
|
||||
|
||||
export const NIP46_PERMS =
|
||||
"nip44_encrypt,nip44_decrypt," +
|
||||
[CLIENT_AUTH, RELAY_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
|
||||
[
|
||||
CLIENT_AUTH,
|
||||
RELAY_JOIN,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
COMMENT,
|
||||
ROOMS,
|
||||
WRAP,
|
||||
REACTION,
|
||||
ZAP_REQUEST,
|
||||
]
|
||||
.map(k => `sign_event:${k}`)
|
||||
.join(",")
|
||||
|
||||
@@ -256,7 +268,7 @@ if (ENABLE_ZAPS) {
|
||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||
}
|
||||
|
||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD]
|
||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
|
||||
|
||||
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
COMMENT,
|
||||
DELETE,
|
||||
getTagValue,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
makeChatPath,
|
||||
makeGoalPath,
|
||||
makeThreadPath,
|
||||
makeClassifiedPath,
|
||||
makeCalendarPath,
|
||||
makeSpaceChatPath,
|
||||
makeRoomPath,
|
||||
@@ -106,12 +108,14 @@ export const setChecked = (key: string) => checked.update(state => ({...state, [
|
||||
|
||||
const goalCommentFilters = [{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}]
|
||||
const threadCommentFilters = [{kinds: [COMMENT], "#K": [String(THREAD)]}]
|
||||
const classifiedCommentFilters = [{kinds: [COMMENT], "#K": [String(CLASSIFIED)]}]
|
||||
const calendarCommentFilters = [{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}]
|
||||
const messageFilters = [{kinds: MESSAGE_KINDS}]
|
||||
const dmFilters = [{kinds: DM_KINDS}]
|
||||
const allFilters = flatten([
|
||||
goalCommentFilters,
|
||||
threadCommentFilters,
|
||||
classifiedCommentFilters,
|
||||
calendarCommentFilters,
|
||||
messageFilters,
|
||||
dmFilters,
|
||||
@@ -129,6 +133,7 @@ export const notifications = derived(
|
||||
relaysByUrl,
|
||||
deriveEventsByIdByUrl({tracker, repository, filters: goalCommentFilters}),
|
||||
deriveEventsByIdByUrl({tracker, repository, filters: threadCommentFilters}),
|
||||
deriveEventsByIdByUrl({tracker, repository, filters: classifiedCommentFilters}),
|
||||
deriveEventsByIdByUrl({tracker, repository, filters: calendarCommentFilters}),
|
||||
deriveEventsByIdByUrl({tracker, repository, filters: messageFilters}),
|
||||
],
|
||||
@@ -143,6 +148,7 @@ export const notifications = derived(
|
||||
$relaysByUrl,
|
||||
goalCommentsByUrl,
|
||||
threadCommentsByUrl,
|
||||
classifiedCommentsByUrl,
|
||||
calendarCommentsByUrl,
|
||||
messagesByUrl,
|
||||
]) => {
|
||||
@@ -181,10 +187,12 @@ export const notifications = derived(
|
||||
const spacePathMobile = spacePath + ":mobile"
|
||||
const goalPath = makeGoalPath(url)
|
||||
const threadPath = makeThreadPath(url)
|
||||
const classifiedPath = makeClassifiedPath(url)
|
||||
const calendarPath = makeCalendarPath(url)
|
||||
const messagesPath = makeSpaceChatPath(url)
|
||||
const goalComments = sortEventsDesc(goalCommentsByUrl.get(url)?.values() || [])
|
||||
const threadComments = sortEventsDesc(threadCommentsByUrl.get(url)?.values() || [])
|
||||
const classifiedComments = sortEventsDesc(classifiedCommentsByUrl.get(url)?.values() || [])
|
||||
const calendarComments = sortEventsDesc(calendarCommentsByUrl.get(url)?.values() || [])
|
||||
const messages = sortEventsDesc(messagesByUrl.get(url)?.values() || [])
|
||||
|
||||
@@ -224,6 +232,24 @@ export const notifications = derived(
|
||||
}
|
||||
}
|
||||
|
||||
const commentsByClassifiedId = groupBy(
|
||||
e => getTagValue("E", e.tags),
|
||||
classifiedComments.filter(spec({kind: COMMENT})),
|
||||
)
|
||||
|
||||
for (const [classifiedId, [comment]] of commentsByClassifiedId.entries()) {
|
||||
const classifiedItemPath = makeClassifiedPath(url, classifiedId)
|
||||
|
||||
if (hasNotification(classifiedPath, comment)) {
|
||||
paths.add(spacePathMobile)
|
||||
paths.add(classifiedPath)
|
||||
}
|
||||
|
||||
if (hasNotification(classifiedItemPath, comment)) {
|
||||
paths.add(classifiedItemPath)
|
||||
}
|
||||
}
|
||||
|
||||
const commentsByEventId = groupBy(
|
||||
e => getTagValue("E", e.tags),
|
||||
calendarComments.filter(spec({kind: COMMENT})),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DIRECT_MESSAGE_FILE,
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
getPubkeyTagValues,
|
||||
@@ -66,6 +67,9 @@ export const makeGoalPath = (url: string, eventId?: string) => makeSpacePath(url
|
||||
export const makeThreadPath = (url: string, eventId?: string) =>
|
||||
makeSpacePath(url, "threads", eventId)
|
||||
|
||||
export const makeClassifiedPath = (url: string, eventId?: string) =>
|
||||
makeSpacePath(url, "classifieds", eventId)
|
||||
|
||||
export const makeCalendarPath = (url: string, eventId?: string) =>
|
||||
makeSpacePath(url, "calendar", eventId)
|
||||
|
||||
@@ -121,6 +125,10 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
return makeThreadPath(url, event.id)
|
||||
}
|
||||
|
||||
if (event.kind === CLASSIFIED) {
|
||||
return makeClassifiedPath(url, event.id)
|
||||
}
|
||||
|
||||
if (event.kind === EVENT_TIME) {
|
||||
return makeCalendarPath(url, event.id)
|
||||
}
|
||||
@@ -141,6 +149,10 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
|
||||
return makeThreadPath(url, id)
|
||||
}
|
||||
|
||||
if (parseInt(kind) === CLASSIFIED) {
|
||||
return makeClassifiedPath(url, id)
|
||||
}
|
||||
|
||||
if (parseInt(kind) === EVENT_TIME) {
|
||||
return makeCalendarPath(url, id)
|
||||
}
|
||||
@@ -158,6 +170,8 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
||||
switch (event.kind) {
|
||||
case THREAD:
|
||||
return makeThreadPath(url, event.id)
|
||||
case CLASSIFIED:
|
||||
return makeClassifiedPath(url, event.id)
|
||||
case ZAP_GOAL:
|
||||
return makeGoalPath(url, event.id)
|
||||
case EVENT_TIME:
|
||||
|
||||
@@ -8,12 +8,8 @@ import {
|
||||
ALERT_WEB,
|
||||
APP_DATA,
|
||||
BLOSSOM_SERVERS,
|
||||
DIRECT_MESSAGE_FILE,
|
||||
DIRECT_MESSAGE,
|
||||
EVENT_TIME,
|
||||
FOLLOWS,
|
||||
MESSAGING_RELAYS,
|
||||
MESSAGE,
|
||||
MUTES,
|
||||
PROFILE,
|
||||
RELAY_ADD_MEMBER,
|
||||
@@ -30,8 +26,6 @@ import {
|
||||
ROOM_DELETE,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOMS,
|
||||
THREAD,
|
||||
ZAP_GOAL,
|
||||
verifiedSymbol,
|
||||
} from "@welshman/util"
|
||||
import type {Zapper, TrustedEvent, RelayProfile} from "@welshman/util"
|
||||
@@ -53,6 +47,7 @@ import {
|
||||
} from "@welshman/app"
|
||||
import {isMobile} from "@lib/html"
|
||||
import type {IDBTable} from "@lib/indexeddb"
|
||||
import {MESSAGE_KINDS, DM_KINDS} from "@app/core/state"
|
||||
|
||||
const kinds = {
|
||||
meta: [PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, MESSAGING_RELAYS, APP_DATA, ROOMS],
|
||||
@@ -67,7 +62,7 @@ const kinds = {
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
],
|
||||
content: [EVENT_TIME, THREAD, MESSAGE, ZAP_GOAL, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE],
|
||||
content: [...MESSAGE_KINDS, ...DM_KINDS],
|
||||
}
|
||||
|
||||
const rankEvent = (event: TrustedEvent) => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {prop} from "@welshman/lib"
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import {currencyOptions} from "src/util/i18n"
|
||||
import SearchSelect from "src/partials/SearchSelect.svelte"
|
||||
|
||||
export let value
|
||||
|
||||
const getKey = prop("code")
|
||||
const termToItem = code => ({name: code, code})
|
||||
const search = fuzzy(currencyOptions, {keys: ["name", "code"], threshold: 0.4})
|
||||
const defaultCodes = ["BTC", "SAT", "USD", "GBP", "AUD", "CAD"]
|
||||
const defaultOptions = currencyOptions.filter(c => defaultCodes.includes(c.code))
|
||||
</script>
|
||||
|
||||
<SearchSelect bind:value {getKey} {termToItem} {defaultOptions} {search}>
|
||||
<span slot="before">
|
||||
<i class="fa fa-right-left" />
|
||||
</span>
|
||||
<div slot="item" let:item>
|
||||
{item.name} ({item.code})
|
||||
</div>
|
||||
</SearchSelect>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {currencyOptions} from "src/util/i18n"
|
||||
|
||||
export let code
|
||||
</script>
|
||||
|
||||
{#if code.match(/^sats?$/i)}
|
||||
<span style="font-family: Satoshis; font-size: 1.2em;">!</span>
|
||||
{:else}
|
||||
{currencyOptions.find(c => c.code)?.symbol || code}
|
||||
{/if}
|
||||
@@ -27,7 +27,7 @@
|
||||
"relative text-base-content text-base-content flex-grow pointer-events-auto",
|
||||
"rounded-t-box sm:rounded-box",
|
||||
{
|
||||
"bg-alt shadow-m max-h-[90vh] flex flex-col": !fullscreen,
|
||||
"bg-alt shadow-m max-h-[90vh] flex flex-col max-w-full": !fullscreen,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
export const currencyOptions = [
|
||||
{name: "Afghan afghani", symbol: "؋", code: "AFN"},
|
||||
{name: "Albanian lek", symbol: "Lek", code: "ALL"},
|
||||
{name: "Algerian dinar", symbol: "DA", code: "DZD"},
|
||||
{name: "Angolan kwanza", symbol: "Kz", code: "AOA"},
|
||||
{name: "Argentine peso", symbol: "$", code: "ARS"},
|
||||
{name: "Armenian dram", symbol: "֏", code: "AMD"},
|
||||
{name: "Aruban florin", symbol: "ƒ", code: "AWG"},
|
||||
{name: "Australian dollar", symbol: "$", code: "AUD"},
|
||||
{name: "Azerbaijani manat", symbol: "₼", code: "AZN"},
|
||||
{name: "Bahamian dollar", symbol: "$", code: "BSD"},
|
||||
{name: "Bahraini dinar", symbol: "BD", code: "BHD"},
|
||||
{name: "Bangladeshi taka", symbol: "৳", code: "BDT"},
|
||||
{name: "Barbadian dollar", symbol: "$", code: "BBD"},
|
||||
{name: "Belarusian ruble", symbol: "Br", code: "BYN"},
|
||||
{name: "Belize dollar", symbol: "$", code: "BZD"},
|
||||
{name: "Bermudian dollar", symbol: "$", code: "BMD"},
|
||||
{name: "Bhutanese ngultrum", symbol: "Nu", code: "BTN"},
|
||||
{name: "Bitcoin", symbol: "!", code: "SAT"},
|
||||
{name: "Bitcoin", symbol: "₿", code: "BTC"},
|
||||
{name: "Bolivian boliviano", symbol: "Bs", code: "BOB"},
|
||||
{name: "Bosnia and Herzegovina convertible mark", symbol: "KM", code: "BAM"},
|
||||
{name: "Botswana pula", symbol: "P", code: "BWP"},
|
||||
{name: "Brazilian real", symbol: "R$", code: "BRL"},
|
||||
{name: "Brunei dollar", symbol: "$", code: "BND"},
|
||||
{name: "Bulgarian lev", symbol: "Lev", code: "BGN"},
|
||||
{name: "Burmese kyat", symbol: "K", code: "MMK"},
|
||||
{name: "Burundian franc", symbol: "Fr", code: "BIF"},
|
||||
{name: "CFP franc", symbol: "Fr", code: "XPF"},
|
||||
{name: "Cambodian riel", symbol: "៛", code: "KHR"},
|
||||
{name: "Canadian Dollar", symbol: "$", code: "CAD"},
|
||||
{name: "Cape Verdean escudo", symbol: "$", code: "CVE"},
|
||||
{name: "Cayman Islands dollar", symbol: "$", code: "KYD"},
|
||||
{name: "Central African CFA franc", symbol: "Fr", code: "XAF"},
|
||||
{name: "Chilean peso", symbol: "$", code: "CLP"},
|
||||
{name: "Colombian peso", symbol: "$", code: "COP"},
|
||||
{name: "Comorian franc", symbol: "Fr", code: "KMF"},
|
||||
{name: "Congolese franc", symbol: "Fr", code: "CDF"},
|
||||
{name: "Costa Rican colón", symbol: "₡", code: "CRC"},
|
||||
{name: "Cuban peso", symbol: "$", code: "CUP"},
|
||||
{name: "Czech koruna", symbol: "Kč", code: "CZK"},
|
||||
{name: "Danish krone", symbol: "kr", code: "DKK"},
|
||||
{name: "Djiboutian franc", symbol: "Fr", code: "DJF"},
|
||||
{name: "Dominican peso", symbol: "$", code: "DOP"},
|
||||
{name: "Eastern Caribbean dollar", symbol: "$", code: "XCD"},
|
||||
{name: "Egyptian pound", symbol: "LE", code: "EGP"},
|
||||
{name: "Eritrean nakfa", symbol: "Nkf", code: "ERN"},
|
||||
{name: "Ethiopian birr", symbol: "Br", code: "ETB"},
|
||||
{name: "Euro", symbol: "€", code: "EUR"},
|
||||
{name: "Falkland Islands pound", symbol: "£", code: "FKP"},
|
||||
{name: "Fijian dollar", symbol: "$", code: "FJD"},
|
||||
{name: "Gambian dalasi", symbol: "D", code: "GMD"},
|
||||
{name: "Georgian lari", symbol: "₾", code: "GEL"},
|
||||
{name: "Ghanaian cedi", symbol: "₵", code: "GHS"},
|
||||
{name: "Gibraltar pound", symbol: "£", code: "GIP"},
|
||||
{name: "Guatemalan quetzal", symbol: "Q", code: "GTQ"},
|
||||
{name: "Guinean franc", symbol: "Fr", code: "GNF"},
|
||||
{name: "Guyanese dollar", symbol: "$", code: "GYD"},
|
||||
{name: "Haitian gourde", symbol: "G", code: "HTG"},
|
||||
{name: "Honduran lempira", symbol: "L", code: "HNL"},
|
||||
{name: "Hong Kong dollar", symbol: "$", code: "HKD"},
|
||||
{name: "Hungarian forint", symbol: "Ft", code: "HUF"},
|
||||
{name: "Icelandic króna", symbol: "kr", code: "ISK"},
|
||||
{name: "Indian rupee", symbol: "₹", code: "INR"},
|
||||
{name: "Indonesian rupiah", symbol: "Rp", code: "IDR"},
|
||||
{name: "Iranian rial", symbol: "Rl", code: "IRR"},
|
||||
{name: "Iraqi dinar", symbol: "ID", code: "IQD"},
|
||||
{name: "Israeli new shekel", symbol: "₪", code: "ILS"},
|
||||
{name: "Jamaican dollar", symbol: "$", code: "JMD"},
|
||||
{name: "Japanese yen", symbol: "¥", code: "JPY"},
|
||||
{name: "Jordanian dinar", symbol: "JD", code: "JOD"},
|
||||
{name: "Kazakhstani tenge", symbol: "₸", code: "KZT"},
|
||||
{name: "Kenyan shilling", symbol: "Sh", code: "KES"},
|
||||
{name: "Kuwaiti dinar", symbol: "KD", code: "KWD"},
|
||||
{name: "Kyrgyz som", symbol: "som", code: "KGS"},
|
||||
{name: "Lao kip", symbol: "₭", code: "LAK"},
|
||||
{name: "Lebanese pound", symbol: "LL", code: "LBP"},
|
||||
{name: "Lesotho loti", symbol: "L", code: "LSL"},
|
||||
{name: "Liberian dollar", symbol: "$", code: "LRD"},
|
||||
{name: "Libyan dinar", symbol: "LD", code: "LYD"},
|
||||
{name: "Macanese pataca", symbol: "MOP$", code: "MOP"},
|
||||
{name: "Macedonian denar", symbol: "DEN", code: "MKD"},
|
||||
{name: "Malagasy ariary", symbol: "Ar", code: "MGA"},
|
||||
{name: "Malawian kwacha", symbol: "K", code: "MWK"},
|
||||
{name: "Malaysian ringgit", symbol: "RM", code: "MYR"},
|
||||
{name: "Maldivian rufiyaa", symbol: "Rf", code: "MVR"},
|
||||
{name: "Mauritanian ouguiya", symbol: "UM", code: "MRU"},
|
||||
{name: "Mauritian rupee", symbol: "Re", code: "MUR"},
|
||||
{name: "Mexican peso", symbol: "$", code: "MXN"},
|
||||
{name: "Moldovan leu", symbol: "Leu", code: "MDL"},
|
||||
{name: "Mongolian tögrög", symbol: "₮", code: "MNT"},
|
||||
{name: "Moroccan dirham", symbol: "DH", code: "MAD"},
|
||||
{name: "Mozambican metical", symbol: "Mt", code: "MZN"},
|
||||
{name: "Namibian dollar", symbol: "$", code: "NAD"},
|
||||
{name: "Nepalese rupee", symbol: "Re", code: "NPR"},
|
||||
{name: "Netherlands Antillean guilder", symbol: "ƒ", code: "ANG"},
|
||||
{name: "New Taiwan dollar", symbol: "$", code: "TWD"},
|
||||
{name: "New Zealand dollar", symbol: "$", code: "NZD"},
|
||||
{name: "Nicaraguan córdoba", symbol: "C$", code: "NIO"},
|
||||
{name: "Nigerian naira", symbol: "₦", code: "NGN"},
|
||||
{name: "North Korean won", symbol: "₩", code: "KPW"},
|
||||
{name: "Norwegian krone", symbol: "kr", code: "NOK"},
|
||||
{name: "Omani rial", symbol: "RO", code: "OMR"},
|
||||
{name: "Pakistani rupee", symbol: "Re", code: "PKR"},
|
||||
{name: "Panamanian balboa", symbol: "B/", code: "PAB"},
|
||||
{name: "Papua New Guinean kina", symbol: "K", code: "PGK"},
|
||||
{name: "Paraguayan guaraní", symbol: "₲", code: "PYG"},
|
||||
{name: "Peruvian sol", symbol: "S/", code: "PEN"},
|
||||
{name: "Philippine peso", symbol: "₱", code: "PHP"},
|
||||
{name: "Polish złoty", symbol: "zł", code: "PLN"},
|
||||
{name: "Qatari riyal", symbol: "QR", code: "QAR"},
|
||||
{name: "Renminbi", symbol: "¥", code: "CNY"},
|
||||
{name: "Romanian leu", symbol: "Leu", code: "RON"},
|
||||
{name: "Russian ruble", symbol: "₽", code: "RUB"},
|
||||
{name: "Rwandan franc", symbol: "Fr", code: "RWF"},
|
||||
{name: "Saint Helena pound", symbol: "£", code: "SHP"},
|
||||
{name: "Samoan tālā", symbol: "$", code: "WST"},
|
||||
{name: "São Tomé and Príncipe dobra", symbol: "Db", code: "STN"},
|
||||
{name: "Saudi riyal", symbol: "Rl", code: "SAR"},
|
||||
{name: "Serbian dinar", symbol: "DIN", code: "RSD"},
|
||||
{name: "Seychellois rupee", symbol: "Re", code: "SCR"},
|
||||
{name: "Sierra Leonean leone", symbol: "Le", code: "SLE"},
|
||||
{name: "Singapore dollar", symbol: "$", code: "SGD"},
|
||||
{name: "Solomon Islands dollar", symbol: "$", code: "SBD"},
|
||||
{name: "Somali shilling", symbol: "Sh", code: "SOS"},
|
||||
{name: "South African rand", symbol: "R", code: "ZAR"},
|
||||
{name: "South Korean won", symbol: "₩", code: "KRW"},
|
||||
{name: "South Sudanese pound", symbol: "(none)", code: "SSP"},
|
||||
{name: "Sri Lankan rupee", symbol: "Re", code: "LKR"},
|
||||
{name: "Sterling", symbol: "£", code: "GBP"},
|
||||
{name: "Sudanese pound", symbol: "LS", code: "SDG"},
|
||||
{name: "Surinamese dollar", symbol: "$", code: "SRD"},
|
||||
{name: "Swazi lilangeni", symbol: "L", code: "SZL"},
|
||||
{name: "Swedish krona", symbol: "kr", code: "SEK"},
|
||||
{name: "Swiss franc", symbol: "Fr", code: "CHF"},
|
||||
{name: "Syrian pound", symbol: "LS", code: "SYP"},
|
||||
{name: "Tajikistani somoni", symbol: "SM", code: "TJS"},
|
||||
{name: "Tanzanian shilling", symbol: "Sh", code: "TZS"},
|
||||
{name: "Thai baht", symbol: "฿", code: "THB"},
|
||||
{name: "Tongan paʻanga", symbol: "T$", code: "TOP"},
|
||||
{name: "Trinidad and Tobago dollar", symbol: "$", code: "TTD"},
|
||||
{name: "Tunisian dinar", symbol: "DT", code: "TND"},
|
||||
{name: "Turkish lira", symbol: "₺", code: "TRY"},
|
||||
{name: "Turkmenistani manat", symbol: "m", code: "TMT"},
|
||||
{name: "Ugandan shilling", symbol: "Sh", code: "UGX"},
|
||||
{name: "Ukrainian hryvnia", symbol: "₴", code: "UAH"},
|
||||
{name: "United Arab Emirates dirham", symbol: "Dh", code: "AED"},
|
||||
{name: "United States dollar", symbol: "$", code: "USD"},
|
||||
{name: "Uruguayan peso", symbol: "$", code: "UYU"},
|
||||
{name: "Uzbekistani sum", symbol: "soum", code: "UZS"},
|
||||
{name: "Vanuatu vatu", symbol: "VT", code: "VUV"},
|
||||
{name: "Venezuelan digital bolívar", symbol: "Bs.D", code: "VED"},
|
||||
{name: "Venezuelan sovereign bolívar", symbol: "Bs.S", code: "VES"},
|
||||
{name: "Vietnamese đồng", symbol: "₫", code: "VND"},
|
||||
{name: "West African CFA franc", symbol: "Fr", code: "XOF"},
|
||||
{name: "Yemeni rial", symbol: "Rl", code: "YER"},
|
||||
{name: "Zambian kwacha", symbol: "K", code: "ZMW"},
|
||||
]
|
||||
|
||||
export const defaultCurrencyOption = currencyOptions.find(c => c.code === "SAT")
|
||||
|
||||
export const getCurrencyOption = code =>
|
||||
currencyOptions.find(c => c.code === code) || {name: code, symbol: "$", code}
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {readable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {CLASSIFIED, getTagValue} from "@welshman/util"
|
||||
import {fly} from "@lib/transition"
|
||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||
import {decodeRelay} from "@app/core/state"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
import {makeCommentFilter} from "@app/core/state"
|
||||
import {makeFeed} from "@app/core/requests"
|
||||
import {pushModal} from "@app/util/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
|
||||
let loading = $state(true)
|
||||
let element: HTMLElement | undefined = $state()
|
||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
||||
|
||||
const createClassified = () => pushModal(ClassifiedCreate, {url})
|
||||
|
||||
const items = $derived.by(() => {
|
||||
const scores = new Map<string, number[]>()
|
||||
const [goals, comments] = partition(spec({kind: CLASSIFIED}), $events)
|
||||
|
||||
for (const comment of comments) {
|
||||
const id = getTagValue("E", comment.tags)
|
||||
|
||||
if (id) {
|
||||
pushToMapKey(scores, id, comment.created_at)
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const feed = makeFeed({
|
||||
url,
|
||||
element: element!,
|
||||
filters: [{kinds: [CLASSIFIED]}, makeCommentFilter([CLASSIFIED])],
|
||||
onExhausted: () => {
|
||||
loading = false
|
||||
},
|
||||
})
|
||||
|
||||
events = feed.events
|
||||
|
||||
return () => {
|
||||
feed.cleanup()
|
||||
setChecked($page.url.pathname)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div class="center">
|
||||
<Icon icon={NotesMinimalistic} />
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<strong>Classified Listings</strong>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div class="row-2">
|
||||
<Button class="btn btn-primary btn-sm" onclick={createClassified}>
|
||||
<Icon icon={NotesMinimalistic} />
|
||||
Create a Listing
|
||||
</Button>
|
||||
<SpaceMenuButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
|
||||
{#each items as event (event.id)}
|
||||
<div in:fly>
|
||||
<ClassifiedItem {url} event={$state.snapshot(event)} />
|
||||
</div>
|
||||
{/each}
|
||||
<p class="flex h-10 items-center justify-center py-20">
|
||||
<Spinner {loading}>
|
||||
{#if loading}
|
||||
Looking for listingss...
|
||||
{:else if items.length === 0}
|
||||
No classified listings found.
|
||||
{:else}
|
||||
That's all!
|
||||
{/if}
|
||||
</Spinner>
|
||||
</p>
|
||||
</PageContent>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {page} from "$app/stores"
|
||||
import {sleep} from "@welshman/lib"
|
||||
import type {MakeNonOptional} from "@welshman/lib"
|
||||
import {COMMENT, getTagValue} from "@welshman/util"
|
||||
import {repository} from "@welshman/app"
|
||||
import {request} from "@welshman/net"
|
||||
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import PageBar from "@lib/components/PageBar.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
import SpaceMenuButton from "@app/components/SpaceMenuButton.svelte"
|
||||
import ClassifiedActions from "@app/components/ClassifiedActions.svelte"
|
||||
import CommentActions from "@app/components/CommentActions.svelte"
|
||||
import EventReply from "@app/components/EventReply.svelte"
|
||||
import {deriveEvent, decodeRelay} from "@app/core/state"
|
||||
import {setChecked} from "@app/util/notifications"
|
||||
|
||||
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
||||
const url = decodeRelay(relay)
|
||||
const event = deriveEvent(id, [url])
|
||||
const filters = [{kinds: [COMMENT], "#E": [id]}]
|
||||
const replies = deriveEventsAsc(deriveEventsById({filters, repository}))
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const openReply = () => {
|
||||
showReply = true
|
||||
}
|
||||
|
||||
const closeReply = () => {
|
||||
showReply = false
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
showAll = true
|
||||
}
|
||||
|
||||
let showAll = $state(false)
|
||||
let showReply = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
request({relays: [url], filters, signal: controller.signal})
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
setChecked($page.url.pathname)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<PageBar>
|
||||
{#snippet icon()}
|
||||
<div>
|
||||
<Button class="btn btn-neutral btn-sm flex-nowrap whitespace-nowrap" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
<span class="hidden sm:inline">Go back</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<div>
|
||||
<SpaceMenuButton {url} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageBar>
|
||||
|
||||
<PageContent class="flex flex-col p-2 pt-4">
|
||||
{#if $event}
|
||||
<div class="flex flex-col gap-3">
|
||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={$event} {url} />
|
||||
<ClassifiedActions showRoom event={$event} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{#if !showAll && $replies.length > 4}
|
||||
<div class="flex justify-center">
|
||||
<Button class="btn btn-link" onclick={expand}>
|
||||
<Icon icon={SortVertical} />
|
||||
Show all {$replies.length} replies
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={reply} {url} />
|
||||
<CommentActions segment="classifieds" event={reply} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showReply}
|
||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
||||
{:else}
|
||||
<div class="flex justify-end p-2">
|
||||
<Button class="btn btn-primary" onclick={openReply}>
|
||||
<Icon icon={Reply} />
|
||||
Reply to listing
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#await sleep(5000)}
|
||||
<Spinner loading>Loading listing...</Spinner>
|
||||
{:then}
|
||||
<p>Failed to load classified listing.</p>
|
||||
{/await}
|
||||
{/if}
|
||||
</PageContent>
|
||||
@@ -102,7 +102,7 @@
|
||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={reply} {url} />
|
||||
<CommentActions event={reply} {url} />
|
||||
<CommentActions segment="goals" event={reply} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import {
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
COMMENT,
|
||||
@@ -26,7 +27,12 @@
|
||||
import NoteItem from "@app/components/NoteItem.svelte"
|
||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
||||
import {makeThreadPath, makeCalendarPath, makeGoalPath} from "@app/util/routes"
|
||||
import {
|
||||
makeThreadPath,
|
||||
makeClassifiedPath,
|
||||
makeCalendarPath,
|
||||
makeGoalPath,
|
||||
} from "@app/util/routes"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
const since = ago(MONTH)
|
||||
@@ -133,6 +139,11 @@
|
||||
View Thread
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Link>
|
||||
{:else if event.kind === CLASSIFIED}
|
||||
<Link href={makeClassifiedPath(url, event.id)} class="btn btn-primary btn-sm">
|
||||
View Listing
|
||||
<Icon icon={AltArrowRight} />
|
||||
</Link>
|
||||
{:else if event.kind === ZAP_GOAL}
|
||||
<Link href={makeGoalPath(url, event.id)} class="btn btn-primary btn-sm">
|
||||
View Goal
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
||||
<div class="col-3 ml-12">
|
||||
<Content showEntire event={reply} {url} />
|
||||
<CommentActions event={reply} {url} />
|
||||
<CommentActions segment="threads" event={reply} {url} />
|
||||
</div>
|
||||
</NoteCard>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user