mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-10 10:57:04 +00:00
Work on thread detail page
This commit is contained in:
@@ -181,7 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-editor .tiptap[contenteditable="true"] {
|
.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 {
|
.tiptap pre code {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
loadRelay,
|
loadRelay,
|
||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import {
|
import {
|
||||||
|
REPLY,
|
||||||
tagRoom,
|
tagRoom,
|
||||||
userMembership,
|
userMembership,
|
||||||
MEMBERSHIPS,
|
MEMBERSHIPS,
|
||||||
@@ -301,31 +302,52 @@ export const sendWrapped = async ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeReaction = ({
|
export type ReactionParams = {
|
||||||
event,
|
|
||||||
content,
|
|
||||||
tags = [],
|
|
||||||
}: {
|
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
content: string
|
content: string
|
||||||
tags?: string[][]
|
tags?: string[][]
|
||||||
}) =>
|
}
|
||||||
createEvent(REACTION, {
|
|
||||||
content,
|
|
||||||
tags: [...tags, ...tagReactionTo(event)],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const publishReaction = ({
|
export const makeReaction = ({event, content, tags = []}: ReactionParams) =>
|
||||||
relays,
|
createEvent(REACTION, {content, tags: [...tags, ...tagReactionTo(event)]})
|
||||||
event,
|
|
||||||
content,
|
export const publishReaction = ({relays, ...params}: ReactionParams & {relays: string[]}) =>
|
||||||
tags = [],
|
publishThunk({event: makeReaction(params), relays})
|
||||||
}: {
|
|
||||||
relays: string[]
|
export type ReplyParams = {
|
||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
content: string
|
content: string
|
||||||
tags?: 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}) =>
|
export const makeDelete = ({event}: {event: TrustedEvent}) =>
|
||||||
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})
|
createEvent(DELETE, {tags: [["k", String(event.kind)], ...tagEvent(event)]})
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {hash, uniqBy, groupBy} from "@welshman/lib"
|
import {hash} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
|
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
|
||||||
import type {Thunk} 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 {slideAndFade, conditionalTransition} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Delay from "@lib/components/Delay.svelte"
|
import Delay from "@lib/components/Delay.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||||
|
import Reactions from "@app/components/Reactions.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ChannelThread from "@app/components/ChannelThread.svelte"
|
import ChannelThread from "@app/components/ChannelThread.svelte"
|
||||||
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
|
||||||
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.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 {publishDelete, publishReaction} from "@app/commands"
|
||||||
import {pushModal, pushDrawer} from "@app/modal"
|
import {pushDrawer} from "@app/modal"
|
||||||
|
|
||||||
export let url
|
export let url, room
|
||||||
export let room
|
|
||||||
export let event: TrustedEvent
|
export let event: TrustedEvent
|
||||||
export let thunk: Thunk
|
export let thunk: Thunk
|
||||||
export let showPubkey = false
|
export let showPubkey = false
|
||||||
@@ -31,8 +27,6 @@
|
|||||||
|
|
||||||
const profile = deriveProfile(event.pubkey)
|
const profile = deriveProfile(event.pubkey)
|
||||||
const profileDisplay = deriveProfileDisplay(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 rootTag = event.tags.find(t => t[0].match(/^e$/i))
|
||||||
const rootId = rootTag?.[1]
|
const rootId = rootTag?.[1]
|
||||||
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
|
const rootHints = [rootTag?.[2]].filter(Boolean) as string[]
|
||||||
@@ -46,7 +40,7 @@
|
|||||||
const openThread = () => {
|
const openThread = () => {
|
||||||
const root = $rootEvent || event
|
const root = $rootEvent || event
|
||||||
|
|
||||||
pushModal(ChannelThread, {url, room, event: root}, {drawer: true})
|
pushDrawer(ChannelThread, {url, room, event: root})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
const onReactionClick = (content: string, events: TrustedEvent[]) => {
|
||||||
@@ -99,37 +93,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $reactions.length > 0 || $replies.length > 0}
|
<Reactions {event} {onReactionClick} showReplies={!isThread} />
|
||||||
<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}
|
|
||||||
<button
|
<button
|
||||||
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all group-hover:opacity-100"
|
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>
|
on:click|stopPropagation>
|
||||||
<ChannelMessageEmojiButton {url} {room} {event} />
|
<ChannelMessageEmojiButton {url} {room} {event} />
|
||||||
<ChannelMessageMenuButton {url} {room} {event} />
|
<ChannelMessageMenuButton {url} {event} />
|
||||||
</button>
|
</button>
|
||||||
</button>
|
</button>
|
||||||
</Delay>
|
</Delay>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
|
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
|
||||||
|
|
||||||
export let url, room, event
|
export let url, event
|
||||||
|
|
||||||
const open = () => popover.show()
|
const open = () => popover.show()
|
||||||
|
|
||||||
@@ -32,6 +32,6 @@
|
|||||||
<Tippy
|
<Tippy
|
||||||
bind:popover
|
bind:popover
|
||||||
component={ChannelMessageMenu}
|
component={ChannelMessageMenu}
|
||||||
props={{url, room, event, onClick}}
|
props={{url, event, onClick}}
|
||||||
params={{trigger: "manual", interactive: true}} />
|
params={{trigger: "manual", interactive: true}} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import ChannelMessage from "@app/components/ChannelMessage.svelte"
|
import ChannelMessage from "@app/components/ChannelMessage.svelte"
|
||||||
import ChannelCompose from "@app/components/ChannelCompose.svelte"
|
import ChannelCompose from "@app/components/ChannelCompose.svelte"
|
||||||
import {tagRoom, REPLY} from "@app/state"
|
import {tagRoom, REPLY} from "@app/state"
|
||||||
|
import {publishReply} from "@app/commands"
|
||||||
|
|
||||||
export let url, room, event: TrustedEvent
|
export let url, room, event: TrustedEvent
|
||||||
|
|
||||||
@@ -19,30 +20,12 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = ({content, tags}: EventContent) => {
|
const onSubmit = ({content, tags}: EventContent) => {
|
||||||
const seenRoots = new Set<string>()
|
const thunk = publishReply({
|
||||||
|
event,
|
||||||
for (const [raw, ...tag] of event.tags.filter(t => t[0].match(/^K|E|A|I$/i))) {
|
content,
|
||||||
const T = raw.toUpperCase()
|
tags: append(tagRoom(room, url), tags),
|
||||||
const t = raw.toLowerCase()
|
relays: [url],
|
||||||
|
})
|
||||||
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]})
|
|
||||||
|
|
||||||
thunks.update(assoc(thunk.event.id, thunk))
|
thunks.update(assoc(thunk.event.id, thunk))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {type Instance} from "tippy.js"
|
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 type {TrustedEvent} from "@welshman/util"
|
||||||
import {deriveEvents} from "@welshman/store"
|
|
||||||
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
|
import {deriveProfile, deriveProfileDisplay, formatTimestampAsTime, pubkey} from "@welshman/app"
|
||||||
import type {MergedThunk} 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 Icon from "@lib/components/Icon.svelte"
|
||||||
import Tippy from "@lib/components/Tippy.svelte"
|
import Tippy from "@lib/components/Tippy.svelte"
|
||||||
import Avatar from "@lib/components/Avatar.svelte"
|
import Avatar from "@lib/components/Avatar.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
import Reactions from "@app/components/Reactions.svelte"
|
||||||
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
import ThunkStatus from "@app/components/ThunkStatus.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import ChatMessageMenu from "@app/components/ChatMessageMenu.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 {pushDrawer} from "@app/modal"
|
||||||
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
|
import {makeDelete, makeReaction, sendWrapped} from "@app/commands"
|
||||||
|
|
||||||
@@ -26,8 +24,6 @@
|
|||||||
|
|
||||||
const profile = deriveProfile(event.pubkey)
|
const profile = deriveProfile(event.pubkey)
|
||||||
const profileDisplay = deriveProfileDisplay(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 [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
|
||||||
|
|
||||||
const showProfile = () => pushDrawer(ProfileDetail, {pubkey: event.pubkey})
|
const showProfile = () => pushDrawer(ProfileDetail, {pubkey: event.pubkey})
|
||||||
@@ -103,25 +99,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $reactions.length > 0 || $zaps.length > 0}
|
<Reactions {event} {onReactionClick} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
import {displayPubkey, getPubkeyTagValues, getListTags} from "@welshman/util"
|
||||||
import {
|
import {
|
||||||
|
session,
|
||||||
userFollows,
|
userFollows,
|
||||||
deriveUserWotScore,
|
deriveUserWotScore,
|
||||||
deriveProfile,
|
deriveProfile,
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
|
|
||||||
const onClick = () => pushDrawer(ProfileDetail, {pubkey})
|
const onClick = () => pushDrawer(ProfileDetail, {pubkey})
|
||||||
|
|
||||||
$: following = getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
|
$: following = pubkey === $session!.pubkey || getPubkeyTagValues(getListTags($userFollows)).includes(pubkey)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex max-w-full gap-3">
|
<div class="flex max-w-full gap-3">
|
||||||
|
|||||||
42
src/app/components/Reactions.svelte
Normal file
42
src/app/components/Reactions.svelte
Normal 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}
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<form class="column gap-4" on:submit|preventDefault={startSubmit}>
|
<form class="column gap-4" on:submit|preventDefault={startSubmit}>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<div slot="title">Create a Thread</div>
|
<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>
|
</ModalHeader>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor flex-grow overflow-hidden">
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
<script lang="ts">
|
<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 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
|
export let event
|
||||||
|
|
||||||
|
const replies = deriveEvents(repository, {filters: [{kinds: [REPLY], "#E": [event.id]}]})
|
||||||
|
|
||||||
|
$: lastActive = max([...$replies, event].map(e => e.created_at))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-end">
|
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
|
||||||
<NoteCard {event} class="card2 bg-alt w-full">
|
<div class="flex w-full justify-between gap-2">
|
||||||
<div class="ml-12">
|
<Profile pubkey={event.pubkey} />
|
||||||
<Content {event} />
|
<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>
|
</div>
|
||||||
</NoteCard>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
|
|||||||
82
src/app/components/ThreadReply.svelte
Normal file
82
src/app/components/ThreadReply.svelte
Normal 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}
|
||||||
@@ -13,6 +13,8 @@ export const makeSpacePath = (url: string, extra = "") => {
|
|||||||
|
|
||||||
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
|
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 getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
|
||||||
|
|
||||||
export const getPrimaryNavItemIndex = ($page: Page) => {
|
export const getPrimaryNavItemIndex = ($page: Page) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {page} from "$app/stores"
|
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 {feedFromFilter, makeIntersectionFeed, makeRelayFeed} from "@welshman/feeds"
|
||||||
import {nthEq} from "@welshman/lib"
|
import {nthEq} from "@welshman/lib"
|
||||||
import {feedLoader} from "@welshman/app"
|
import {feedLoader, userMutes} from "@welshman/app"
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
const feed = makeIntersectionFeed(makeRelayFeed(url), feedFromFilter({kinds}))
|
const feed = makeIntersectionFeed(makeRelayFeed(url), feedFromFilter({kinds}))
|
||||||
const events = deriveEventsForUrl(url, kinds)
|
const events = deriveEventsForUrl(url, kinds)
|
||||||
const loader = feedLoader.getLoader(feed, {})
|
const loader = feedLoader.getLoader(feed, {})
|
||||||
|
const mutedPubkeys = getPubkeyTagValues(getListTags($userMutes))
|
||||||
|
|
||||||
const openMenu = () => pushDrawer(MenuSpace, {url})
|
const openMenu = () => pushDrawer(MenuSpace, {url})
|
||||||
|
|
||||||
@@ -55,16 +56,20 @@
|
|||||||
<Icon icon="notes-minimalistic" />
|
<Icon icon="notes-minimalistic" />
|
||||||
</div>
|
</div>
|
||||||
<strong slot="title">Threads</strong>
|
<strong slot="title">Threads</strong>
|
||||||
<div slot="action" class="md:hidden">
|
<div slot="action" class="row-2">
|
||||||
<Button on:click={openMenu} class="btn btn-neutral btn-sm">
|
<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" />
|
<Icon icon="menu-dots" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</PageBar>
|
</PageBar>
|
||||||
<div class="flex flex-grow flex-col gap-2 overflow-auto p-2" bind:this={element}>
|
<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)}
|
{#each $events.slice(0, limit) as event (event.id)}
|
||||||
{#if !event.tags.some(nthEq(0, "e"))}
|
{#if !event.tags.some(nthEq(0, "e")) && !mutedPubkeys.includes(event.pubkey)}
|
||||||
<ThreadItem {event} />
|
<ThreadItem {url} {event} />
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
@@ -77,12 +82,4 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
62
src/routes/spaces/[relay]/threads/[id]/+page.svelte
Normal file
62
src/routes/spaces/[relay]/threads/[id]/+page.svelte
Normal 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} />
|
||||||
@@ -29,6 +29,7 @@ export default {
|
|||||||
dark: {
|
dark: {
|
||||||
...themes["dark"],
|
...themes["dark"],
|
||||||
primary: process.env.VITE_PLATFORM_ACCENT,
|
primary: process.env.VITE_PLATFORM_ACCENT,
|
||||||
|
"primary-content": "#EAE7FF",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user