Work on thread detail page

This commit is contained in:
Jon Staab
2024-10-23 14:20:24 -07:00
parent 8d0c016621
commit 09028b69a4
15 changed files with 300 additions and 134 deletions

View File

@@ -181,7 +181,7 @@
}
.note-editor .tiptap[contenteditable="true"] {
@apply input input-bordered h-auto min-h-32 p-[.65rem] pb-6;
@apply input input-bordered h-auto min-h-32 p-[.65rem] pb-6 rounded-box;
}
.tiptap pre code {

View File

@@ -46,6 +46,7 @@ import {
loadRelay,
} from "@welshman/app"
import {
REPLY,
tagRoom,
userMembership,
MEMBERSHIPS,
@@ -301,31 +302,52 @@ export const sendWrapped = async ({
)
}
export const makeReaction = ({
event,
content,
tags = [],
}: {
export type ReactionParams = {
event: TrustedEvent
content: string
tags?: string[][]
}) =>
createEvent(REACTION, {
content,
tags: [...tags, ...tagReactionTo(event)],
})
}
export const publishReaction = ({
relays,
event,
content,
tags = [],
}: {
relays: string[]
export const makeReaction = ({event, content, tags = []}: ReactionParams) =>
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]})
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
publishThunk({event: makeReaction(params), relays})
export type ReplyParams = {
event: TrustedEvent
content: string
tags?: string[][]
}) => publishThunk({event: makeReaction({event, content, tags}), relays})
}
export const makeReply = ({event, content, tags = []}: ReplyParams) => {
const seenRoots = new Set<string>()
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^K|E|A|I$/i))) {
const T = raw.toUpperCase()
const t = raw.toLowerCase()
if (seenRoots.has(T)) {
tags.push([t, ...tag])
} else {
tags.push([T, ...tag])
seenRoots.add(T)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["E", event.id])
} else {
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
}
return createEvent(REPLY, {content, tags})
}
export const publishReply = ({relays, ...params}: ReplyParams & {relays: string[]}) =>
publishThunk({event: makeReply(params), relays})
export const makeDelete = ({event}: {event: TrustedEvent}) =>
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})

View File

@@ -1,29 +1,25 @@
<script lang="ts">
import {readable} from "svelte/store"
import {hash, uniqBy, groupBy} from "@welshman/lib"
import {hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {REACTION} from "@welshman/util"
import {repository} from "@welshman/app"
import {slideAndFade, conditionalTransition} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Delay from "@lib/components/Delay.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import Reactions from "@app/components/Reactions.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChannelThread from "@app/components/ChannelThread.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import {REPLY, colors, tagRoom, deriveEvent, displayReaction} from "@app/state"
import {colors, tagRoom, deriveEvent} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushModal, pushDrawer} from "@app/modal"
import {pushDrawer} from "@app/modal"
export let url
export let room
export let url, room
export let event: TrustedEvent
export let thunk: Thunk
export let showPubkey = false
@@ -31,8 +27,6 @@
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const reactions = deriveEvents(repository, {filters: [{kinds: [REACTION], "#e": [event.id]}]})
const replies = deriveEvents(repository, {filters: [{kinds: [REPLY], "#E": [event.id]}]})
const rootTag = event.tags.find(t => t[0].match(/^e$/i))
const rootId = rootTag?.[1]
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
@@ -46,7 +40,7 @@
const openThread = () => {
const root = $rootEvent || event
pushModal(ChannelThread, {url, room, event: root}, {drawer: true})
pushDrawer(ChannelThread, {url, room, event: root})
}
const onReactionClick = (content: string, events: TrustedEvent[]) => {
@@ -99,37 +93,12 @@
</div>
</div>
</div>
{#if $reactions.length > 0 || $replies.length > 0}
<div class="ml-12 flex flex-wrap gap-2 text-xs">
{#if $replies.length > 0 && !isThread}
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
{$replies.length}
</div>
{/if}
{#each groupBy( e => e.content, uniqBy(e => e.pubkey + e.content, $reactions), ).entries() as [content, events]}
{@const isOwn = events.some(e => e.pubkey === $pubkey)}
{@const onClick = () => onReactionClick(content, events)}
<button
type="button"
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full"
class:border={isOwn}
class:border-solid={isOwn}
class:border-primary={isOwn}
on:click|stopPropagation={onClick}>
<span>{displayReaction(content)}</span>
{#if events.length > 1}
<span>{events.length}</span>
{/if}
</button>
{/each}
</div>
{/if}
<Reactions {event} {onReactionClick} showReplies={!isThread} />
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all group-hover:opacity-100"
on:click|stopPropagation>
<ChannelMessageEmojiButton {url} {room} {event} />
<ChannelMessageMenuButton {url} {room} {event} />
<ChannelMessageMenuButton {url} {event} />
</button>
</button>
</Delay>

View File

@@ -6,7 +6,7 @@
import Tippy from "@lib/components/Tippy.svelte"
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
export let url, room, event
export let url, event
const open = () => popover.show()
@@ -32,6 +32,6 @@
<Tippy
bind:popover
component={ChannelMessageMenu}
props={{url, room, event, onClick}}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}} />
</div>

View File

@@ -9,6 +9,7 @@
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import {tagRoom, REPLY} from "@app/state"
import {publishReply} from "@app/commands"
export let url, room, event: TrustedEvent
@@ -19,30 +20,12 @@
})
const onSubmit = ({content, tags}: EventContent) => {
const seenRoots = new Set<string>()
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^K|E|A|I$/i))) {
const T = raw.toUpperCase()
const t = raw.toLowerCase()
if (seenRoots.has(T)) {
tags.push([t, ...tag])
} else {
tags.push([T, ...tag])
seenRoots.add(T)
}
}
if (seenRoots.size === 0) {
tags.push(["K", String(event.kind)])
tags.push(["E", event.id])
} else {
tags.push(["k", String(event.kind)])
tags.push(["e", event.id])
}
const reply = createEvent(REPLY, {content, tags: append(tagRoom(room, url), tags)})
const thunk = publishThunk({event: reply, relays: [url]})
const thunk = publishReply({
event,
content,
tags: append(tagRoom(room, url), tags),
relays: [url],
})
thunks.update(assoc(thunk.event.id, thunk))
}

View File

@@ -1,21 +1,19 @@
<script lang="ts">
import {type Instance} from "tippy.js"
import {hash, uniqBy, groupBy} from "@welshman/lib"
import {hash} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
import type {MergedThunk} from "@welshman/app"
import {REACTION, ZAP_RESPONSE} from "@welshman/util"
import {repository} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import Reactions from "@app/components/Reactions.svelte"
import ThunkStatus from "@app/components/ThunkStatus.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMessageMenu from "@app/components/ChatMessageMenu.svelte"
import {colors, displayReaction} from "@app/state"
import {colors} from "@app/state"
import {pushDrawer} from "@app/modal"
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
@@ -26,8 +24,6 @@
const profile = deriveProfile(event.pubkey)
const profileDisplay = deriveProfileDisplay(event.pubkey)
const reactions = deriveEvents(repository, {filters: [{kinds: [REACTION], "#e": [event.id]}]})
const zaps = deriveEvents(repository, {filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}]})
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const showProfile = () => pushDrawer(ProfileDetail, {pubkey: event.pubkey})
@@ -103,25 +99,6 @@
</div>
</div>
</div>
{#if $reactions.length > 0 || $zaps.length > 0}
<div class="relative z-feature -mt-4 flex justify-end text-xs">
{#each groupBy( e => e.content, uniqBy(e => e.pubkey + e.content, $reactions), ).entries() as [content, events]}
{@const isOwn = events.some(e => e.pubkey === $pubkey)}
{@const onClick = () => onReactionClick(content, events)}
<button
type="button"
class="flex-inline btn btn-neutral btn-xs mr-2 gap-1 rounded-full"
class:border={isOwn}
class:border-solid={isOwn}
class:border-primary={isOwn}
on:click|stopPropagation={onClick}>
<span>{displayReaction(content)}</span>
{#if events.length > 1}
<span>{events.length}</span>
{/if}
</button>
{/each}
</div>
{/if}
<Reactions {event} {onReactionClick} />
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
import {
session,
userFollows,
deriveUserWotScore,
deriveProfile,
@@ -23,7 +24,7 @@
const onClick = () => pushDrawer(ProfileDetail, {pubkey})
$: following = getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
$: following = pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
</script>
<div class="flex max-w-full gap-3">

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import {groupBy, uniqBy} from "@welshman/lib"
import {REACTION} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {pubkey, repository} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import {REPLY, displayReaction} from "@app/state"
export let event
export let onReactionClick
export let showReplies = false
const reactions = deriveEvents(repository, {filters: [{kinds: [REACTION], "#e": [event.id]}]})
const replies = deriveEvents(repository, {filters: [{kinds: [REPLY], "#E": [event.id]}]})
</script>
{#if $reactions.length > 0 || $replies.length > 0}
<div class="ml-12 flex flex-wrap gap-2 text-xs">
{#if $replies.length > 0 && showReplies}
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
{$replies.length}
</div>
{/if}
{#each groupBy( e => e.content, uniqBy(e => e.pubkey + e.content, $reactions), ).entries() as [content, events]}
{@const isOwn = events.some(e => e.pubkey === $pubkey)}
{@const onClick = () => onReactionClick(content, events)}
<button
type="button"
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full"
class:border={isOwn}
class:border-solid={isOwn}
class:border-primary={isOwn}
on:click|stopPropagation={onClick}>
<span>{displayReaction(content)}</span>
{#if events.length > 1}
<span>{events.length}</span>
{/if}
</button>
{/each}
</div>
{/if}

View File

@@ -37,7 +37,7 @@
<form class="column gap-4" on:submit|preventDefault={startSubmit}>
<ModalHeader>
<div slot="title">Create a Thread</div>
<div slot="info">Share your thoughts, or start a discussion.</div>
<div slot="info">Share a link, or start a discussion.</div>
</ModalHeader>
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">

View File

@@ -1,14 +1,42 @@
<script lang="ts">
import {max} from '@welshman/lib'
import {formatTimestamp, formatTimestampRelative} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import {repository} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Divider from "@lib/components/Divider.svelte"
import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import Profile from "@app/components/Profile.svelte"
import {makeThreadPath} from "@app/routes"
import {pushDrawer} from "@app/modal"
import {REPLY} from "@app/state"
export let url
export let event
const replies = deriveEvents(repository, {filters: [{kinds: [REPLY], "#E": [event.id]}]})
$: lastActive = max([...$replies, event].map(e => e.created_at))
</script>
<div class="flex flex-col items-end">
<NoteCard {event} class="card2 bg-alt w-full">
<div class="ml-12">
<Content {event} />
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
<div class="flex w-full justify-between gap-2">
<Profile pubkey={event.pubkey} />
<p class="text-sm opacity-75">
{formatTimestamp(event.created_at)}
</p>
</div>
<div class="pl-12 w-full col-4">
<Content {event} />
<div class="row-2 justify-end">
<div class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full">
<Icon icon="reply" />
<span>{$replies.length} {$replies.length === 1 ? 'reply' : 'replies'}</span>
</div>
<div class="btn btn-neutral btn-xs rounded-full">
Active {formatTimestampRelative(lastActive)}
</div>
</div>
</NoteCard>
</div>
</div>
</Link>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Readable} from "svelte/store"
import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {createEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {fly} from '@lib/transition'
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {REPLY} from "@app/state"
import {getPubkeyHints, publishReply} from "@app/commands"
import {getEditorOptions, addFile, uploadFiles, getEditorTags} from "@lib/editor"
export let url
export let event
const startSubmit = () => uploadFiles($editor)
const loading = writable(false)
const submit = () => {
const thunk = publishReply({
event,
content: $editor.getText(),
tags: getEditorTags($editor),
relays: [url],
})
hide()
}
const show = () => {
visible = true
}
const hide = () => {
visible = false
}
let visible = false
let editor: Readable<Editor>
onMount(() => {
editor = createEditor(getEditorOptions({submit, loading, getPubkeyHints, autofocus: true}))
})
</script>
{#if visible}
<form on:submit|preventDefault={startSubmit} in:fly class="sticky bottom-2 card2 bg-alt mt-2 mx-2">
<div class="relative">
<div class="note-editor flex-grow overflow-hidden">
<EditorContent editor={$editor} />
</div>
<Button
data-tip="Add an image"
class="tooltip tooltip-left absolute bottom-1 right-2"
on:click={() => addFile($editor)}>
{#if $loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon="paperclip" size={3} />
{/if}
</Button>
</div>
<ModalFooter>
<Button class="btn btn-link" on:click={hide}>
Cancel
</Button>
<Button type="submit" class="btn btn-primary">Post Reply</Button>
</ModalFooter>
</form>
{:else}
<div class="flex justify-end p-2">
<Button class="btn btn-primary" on:click={show}>
<Icon icon="reply" />
Reply
</Button>
</div>
{/if}

View File

@@ -13,6 +13,8 @@ export const makeSpacePath = (url: string, extra = "") => {
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
export const makeThreadPath = (url: string, eventId: string) => `/spaces/${encodeRelay(url)}/threads/${eventId}`
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => {

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import {onMount} from "svelte"
import {page} from "$app/stores"
import {NOTE} from "@welshman/util"
import {NOTE, getListTags, getPubkeyTagValues} from "@welshman/util"
import {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
import {nthEq} from "@welshman/lib"
import {feedLoader} from "@welshman/app"
import {feedLoader, userMutes} from "@welshman/app"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -21,6 +21,7 @@
const feed = makeIntersectionFeed(makeRelayFeed(url), feedFromFilter({kinds}))
const events = deriveEventsForUrl(url, kinds)
const loader = feedLoader.getLoader(feed, {})
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
const openMenu = () => pushDrawer(MenuSpace, {url})
@@ -55,16 +56,20 @@
<Icon icon="notes-minimalistic" />
</div>
<strong slot="title">Threads</strong>
<div slot="action" class="md:hidden">
<Button on:click={openMenu} class="btn btn-neutral btn-sm">
<div slot="action" class="row-2">
<Button class="btn btn-primary btn-sm" on:click={createThread}>
<Icon icon="notes-minimalistic" />
Create a Thread
</Button>
<Button on:click={openMenu} class="btn btn-neutral btn-sm md:hidden">
<Icon icon="menu-dots" />
</Button>
</div>
</PageBar>
<div class="flex flex-grow flex-col gap-2 overflow-auto p-2" bind:this={element}>
{#each $events.slice(0, limit) as event (event.id)}
{#if !event.tags.some(nthEq(0, "e"))}
<ThreadItem {event} />
{#if !event.tags.some(nthEq(0, "e")) && !mutedPubkeys.includes(event.pubkey)}
<ThreadItem {url} {event} />
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
@@ -77,12 +82,4 @@
</Spinner>
</p>
</div>
<Button
class="tooltip tooltip-left fixed bottom-16 right-4 z-feature p-1 sm:right-8 md:bottom-4 md:right-4"
data-tip="Create a Thread"
on:click={createThread}>
<div class="btn btn-circle btn-primary flex h-12 w-12 items-center justify-center">
<Icon icon="notes-minimalistic" />
</div>
</Button>
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import {ctx, sortBy} from "@welshman/lib"
import {page} from "$app/stores"
import {pubkey, repository} from "@welshman/app"
import {deriveEvents} from "@welshman/store"
import {getReplyFilters} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Divider from "@lib/components/Divider.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import Content from "@app/components/Content.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import MenuSpace from "@app/components/MenuSpace.svelte"
import Reactions from "@app/components/Reactions.svelte"
import ThreadReply from "@app/components/ThreadReply.svelte"
import {REPLY, deriveEvent, decodeRelay} from "@app/state"
import {publishDelete, publishReaction} from "@app/commands"
import {pushDrawer} from "@app/modal"
const {relay, id} = $page.params
const url = decodeRelay(relay)
const event = deriveEvent(id)
const replies = deriveEvents(repository, {filters: [{kinds: [REPLY], "#E": [id]}]})
const back = () => history.back()
const openMenu = () => pushDrawer(MenuSpace, {url})
const getReactionHandler = (event: TrustedEvent) => (content: string, events: TrustedEvent[]) => {
const reaction = events.find(e => e.pubkey === $pubkey)
if (reaction) {
publishDelete({relays: [url], event: reaction})
} else {
publishReaction({event, content, relays: [url]})
}
}
</script>
<div class="flex flex-col-reverse gap-2 max-h-screen overflow-auto p-2">
{#each sortBy(e => -e.created_at, $replies) as reply (reply.id)}
<NoteCard event={reply} class="w-full border-l border-b border-r border-solid border-neutral -mt-8 pt-12 p-6 rounded-box">
<div class="ml-12">
<Content event={reply} />
<Reactions event={reply} onReactionClick={getReactionHandler(reply)} />
</div>
</NoteCard>
{/each}
<NoteCard event={$event} class="card2 bg-alt w-full py-2 z-feature relative border border-solid border-neutral">
<div class="ml-12">
<Content event={$event} />
<Reactions event={$event} onReactionClick={getReactionHandler($event)} />
</div>
</NoteCard>
<Button class="flex gap-2 mt-5 mb-3 items-center" on:click={back}>
<Icon icon="alt-arrow-left" />
Back to threads
</Button>
</div>
<ThreadReply {url} event={$event} />

View File

@@ -29,6 +29,7 @@ export default {
dark: {
...themes["dark"],
primary: process.env.VITE_PLATFORM_ACCENT,
"primary-content": "#EAE7FF",
},
},
],