forked from coracle/flotilla
358 lines
10 KiB
Svelte
358 lines
10 KiB
Svelte
<script lang="ts">
|
|
import type {Snippet} from "svelte"
|
|
import {onMount} from "svelte"
|
|
import {
|
|
ago,
|
|
int,
|
|
ms,
|
|
partition,
|
|
ifLet,
|
|
spec,
|
|
nthEq,
|
|
nthNe,
|
|
MINUTE,
|
|
sortBy,
|
|
remove,
|
|
enumerate,
|
|
formatTimestampAsDate,
|
|
} from "@welshman/lib"
|
|
import type {TrustedEvent, EventTemplate, EventContent} from "@welshman/util"
|
|
import {parse, isLink} from "@welshman/content"
|
|
import {
|
|
makeEvent,
|
|
tagsFromIMeta,
|
|
getTags,
|
|
DIRECT_MESSAGE,
|
|
DIRECT_MESSAGE_FILE,
|
|
} from "@welshman/util"
|
|
import {
|
|
pubkey,
|
|
tagPubkey,
|
|
sendWrapped,
|
|
mergeThunks,
|
|
loadMessagingRelayList,
|
|
messagingRelayListsByPubkey,
|
|
} from "@welshman/app"
|
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
|
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import Spinner from "@lib/components/Spinner.svelte"
|
|
import PageBar from "@lib/components/PageBar.svelte"
|
|
import PageContent from "@lib/components/PageContent.svelte"
|
|
import Divider from "@lib/components/Divider.svelte"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import ProfileName from "@app/components/ProfileName.svelte"
|
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
|
import ChatMembers from "@app/components/ChatMembers.svelte"
|
|
import ChatMessage from "@app/components/ChatMessage.svelte"
|
|
import ChatCompose from "@app/components/ChatCompose.svelte"
|
|
import ChatComposeEdit from "@app/components/ChatComposeEdit.svelte"
|
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
|
import {userSettingsValues, deriveChat} from "@app/core/state"
|
|
import {pushModal} from "@app/util/modal"
|
|
import {makeDelete, prependParent} from "@app/core/commands"
|
|
import {pushToast} from "@app/util/toast"
|
|
|
|
type Props = {
|
|
pubkeys: string[]
|
|
info?: Snippet
|
|
}
|
|
|
|
const {pubkeys, info}: Props = $props()
|
|
|
|
const chat = deriveChat(pubkeys)
|
|
const others = remove($pubkey!, pubkeys)
|
|
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayListsByPubkey.has(pk)))
|
|
|
|
const showMembers = () =>
|
|
others.length === 1
|
|
? pushModal(ProfileDetail, {pubkey: others[0]})
|
|
: pushModal(ChatMembers, {pubkeys: others})
|
|
|
|
const back = () => history.back()
|
|
|
|
const replyTo = (event: TrustedEvent) => {
|
|
parent = event
|
|
compose?.focus()
|
|
}
|
|
|
|
const clearParent = () => {
|
|
parent = undefined
|
|
}
|
|
|
|
const clearEventToEdit = () => {
|
|
eventToEdit = undefined
|
|
}
|
|
|
|
const onSubmit = async (params: EventContent) => {
|
|
try {
|
|
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
|
|
|
|
// Remove p tags since they result in forking the conversation
|
|
params.tags = params.tags.filter(nthNe(0, "p"))
|
|
|
|
// Add our reply quote to content
|
|
params = prependParent(parent, params)
|
|
|
|
if (eventToEdit) {
|
|
if (eventToEdit.content === params.content) {
|
|
return
|
|
}
|
|
|
|
await sendWrapped({
|
|
event: makeDelete({event: eventToEdit, protect: false}),
|
|
recipients: pubkeys,
|
|
pow: 16,
|
|
})
|
|
}
|
|
|
|
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
|
|
const imetas = getTags("imeta", imetaTags).map(tagsFromIMeta)
|
|
const templates: EventTemplate[] = []
|
|
const buffer = []
|
|
|
|
const addTemplate = (kind: number, content: string, tags: string[][]) => {
|
|
content = content.trim()
|
|
|
|
if (content) {
|
|
templates.push(
|
|
makeEvent(kind, {
|
|
content,
|
|
tags: [...tags, ...ptags],
|
|
created_at: eventToEdit?.created_at,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const p of parse(params)) {
|
|
const imeta = isLink(p)
|
|
? imetas.find(tags => tags.find(spec(["url", p.value.url.toString()])))
|
|
: undefined
|
|
|
|
if (isLink(p) && imeta) {
|
|
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
|
addTemplate(
|
|
DIRECT_MESSAGE_FILE,
|
|
p.value.url.toString(),
|
|
imeta.slice(1).filter(nthNe(0, "url")),
|
|
)
|
|
} else {
|
|
buffer.push(p.raw)
|
|
}
|
|
}
|
|
|
|
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
|
|
|
|
// Split the message into multiple pieces so that we can use kind 15 to send images per nip 17
|
|
// Sleep 1 second between each one to make sure timestamps are distinct
|
|
const thunks = await Promise.all(
|
|
Array.from(enumerate(templates)).map(([i, event]) =>
|
|
sendWrapped({
|
|
event,
|
|
recipients: pubkeys,
|
|
delay: $userSettingsValues.send_delay + ms(i),
|
|
pow: 16,
|
|
}),
|
|
),
|
|
)
|
|
|
|
pushToast({
|
|
timeout: 30_000,
|
|
children: {
|
|
component: ThunkToast,
|
|
props: {thunk: mergeThunks(thunks)},
|
|
},
|
|
})
|
|
} finally {
|
|
clearParent()
|
|
clearEventToEdit()
|
|
}
|
|
}
|
|
|
|
const onEscape = () => {
|
|
clearParent()
|
|
clearEventToEdit()
|
|
}
|
|
|
|
const canEditEvent = (event: TrustedEvent) =>
|
|
event.pubkey === $pubkey &&
|
|
event.kind === DIRECT_MESSAGE &&
|
|
event.created_at >= ago(500, MINUTE)
|
|
|
|
const onEditEvent = (event: TrustedEvent) => {
|
|
clearParent()
|
|
eventToEdit = event
|
|
}
|
|
|
|
const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent)
|
|
|
|
let loading = $state(true)
|
|
let compose: ChatCompose | undefined = $state()
|
|
let parent: TrustedEvent | undefined = $state()
|
|
let eventToEdit: TrustedEvent | undefined = $state()
|
|
let chatCompose: HTMLElement | undefined = $state()
|
|
let dynamicPadding: HTMLElement | undefined = $state()
|
|
|
|
const elements = $derived.by(() => {
|
|
const elements = []
|
|
|
|
let previousDate
|
|
let previousPubkey
|
|
let previousCreatedAt = 0
|
|
|
|
for (const event of sortBy(e => e.created_at, $chat?.messages || [])) {
|
|
const {id, pubkey, created_at} = event
|
|
const date = formatTimestampAsDate(created_at)
|
|
|
|
if (date !== previousDate) {
|
|
elements.push({type: "date", value: date, id: date, showPubkey: false})
|
|
}
|
|
|
|
elements.push({
|
|
id,
|
|
type: "note",
|
|
value: event,
|
|
showPubkey: created_at - previousCreatedAt > int(2, MINUTE) || previousPubkey !== pubkey,
|
|
})
|
|
|
|
previousDate = date
|
|
previousPubkey = pubkey
|
|
previousCreatedAt = created_at
|
|
}
|
|
|
|
return elements.reverse()
|
|
})
|
|
|
|
onMount(() => {
|
|
for (const pubkey of others) {
|
|
loadMessagingRelayList(pubkey)
|
|
}
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
if (dynamicPadding && chatCompose) {
|
|
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
|
}
|
|
})
|
|
|
|
observer.observe(chatCompose!)
|
|
observer.observe(dynamicPadding!)
|
|
|
|
return () => {
|
|
observer.unobserve(chatCompose!)
|
|
observer.unobserve(dynamicPadding!)
|
|
}
|
|
})
|
|
|
|
setTimeout(() => {
|
|
loading = false
|
|
}, 5000)
|
|
</script>
|
|
|
|
<PageBar>
|
|
<div class="flex">
|
|
<Button onclick={back} class="place-self-start pr-3 md:hidden flex items-center">
|
|
<Icon icon={ArrowLeft} size={7} />
|
|
</Button>
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div class="ellipsize flex items-center gap-4 whitespace-nowrap">
|
|
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
|
|
{#if others.length === 0}
|
|
<div class="row-2">
|
|
<ProfileCircle pubkey={$pubkey!} size={5} />
|
|
<ProfileName pubkey={$pubkey!} />
|
|
</div>
|
|
{:else if others.length === 1}
|
|
<div class="row-2">
|
|
<ProfileCircle pubkey={others[0]} size={5} />
|
|
<ProfileName pubkey={others[0]} />
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center gap-2">
|
|
<ProfileCircles pubkeys={others} size={5} />
|
|
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
|
|
<ProfileName pubkey={others[0]} />
|
|
and
|
|
{#if others.length === 2}
|
|
<ProfileName pubkey={others[1]} />
|
|
{:else}
|
|
{others.length - 1}
|
|
{others.length > 2 ? "others" : "other"}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PageBar>
|
|
|
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
|
<div bind:this={dynamicPadding}></div>
|
|
{#if missingRelayLists.length > 0}
|
|
<div class="py-12">
|
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
|
<p class="row-2 text-lg text-error">
|
|
<Icon icon={Danger} />
|
|
Direct messages are not enabled
|
|
</p>
|
|
<p>
|
|
Ask
|
|
{#each missingRelayLists as pubkey (pubkey)}
|
|
<ProfileLink {pubkey} />
|
|
{/each}
|
|
to enable direct messaging by opening this conversation in their app.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{#each elements as { type, id, value, showPubkey } (id)}
|
|
{#if type === "date"}
|
|
<Divider>{value}</Divider>
|
|
{:else}
|
|
<ChatMessage
|
|
event={$state.snapshot(value as TrustedEvent)}
|
|
{pubkeys}
|
|
{showPubkey}
|
|
{replyTo}
|
|
canEdit={canEditEvent}
|
|
onEdit={onEditEvent} />
|
|
{/if}
|
|
{/each}
|
|
<p class="m-auto flex h-10 max-w-sm flex-col items-center justify-center gap-4 py-20 text-center">
|
|
<Spinner {loading}>
|
|
{#if loading}
|
|
Looking for messages...
|
|
{:else}
|
|
End of message history
|
|
{/if}
|
|
</Spinner>
|
|
{@render info?.()}
|
|
</p>
|
|
</PageContent>
|
|
|
|
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
|
<div>
|
|
{#if parent}
|
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
|
{/if}
|
|
{#if eventToEdit}
|
|
<ChatComposeEdit clear={clearEventToEdit} />
|
|
{/if}
|
|
</div>
|
|
{#key eventToEdit}
|
|
<ChatCompose
|
|
bind:this={compose}
|
|
{onSubmit}
|
|
{onEscape}
|
|
{onEditPrevious}
|
|
content={eventToEdit?.content}
|
|
disabled={Boolean(missingRelayLists.length)} />
|
|
{/key}
|
|
</div>
|