flotilla/src/app/components/Chat.svelte

276 lines
8.4 KiB
Svelte
Raw Normal View History

2024-10-23 16:45:06 +00:00
<script lang="ts">
2025-02-06 19:33:01 +00:00
import type {Snippet} from "svelte"
import {onDestroy, onMount} from "svelte"
2025-06-03 23:46:30 +00:00
import {
2026-03-10 22:13:33 +00:00
ago,
2025-06-03 23:46:30 +00:00
int,
ms,
partition,
spec,
nthEq,
nthNe,
MINUTE,
sortBy,
remove,
enumerate,
2025-06-03 23:46:30 +00:00
formatTimestampAsDate,
} from "@welshman/lib"
import type {TrustedEvent, EventTemplate} from "@welshman/util"
import {makeEvent, userInbox, DIRECT_MESSAGE, DIRECT_MESSAGE_FILE} from "@welshman/util"
2025-06-03 23:46:30 +00:00
import {parse, isLink} from "@welshman/content"
2026-07-28 16:16:26 +00:00
import {MessagingRelayLists, Thunks} from "@welshman/app"
2025-09-10 22:26:39 +00:00
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
2026-03-30 21:24:40 +00:00
import ArrowLeft from "@assets/icons/arrow-left.svg?dataurl"
2024-10-23 16:45:06 +00:00
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"
2024-10-23 16:45:06 +00:00
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
2026-03-23 21:21:04 +00:00
import ProfileLink from "@app/components/ProfileLink.svelte"
2024-10-23 16:45:06 +00:00
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
2024-12-16 20:54:17 +00:00
import ProfileDetail from "@app/components/ProfileDetail.svelte"
2025-11-13 21:25:34 +00:00
import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
2024-10-23 16:45:06 +00:00
import ChatMessage from "@app/components/ChatMessage.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {app, deletes, router, user, wraps} from "@app/core"
import {loadSendDelay} from "@app/settings"
import {chats, makeChatId} from "@app/chats"
import {makeFeedContext} from "@app/feeds"
import {navigate, pushModal} from "@app/modal"
2026-07-28 16:16:26 +00:00
import {prependParent} from "@app/rooms"
2026-06-08 21:14:40 +00:00
import {pushToast} from "@app/toast"
2024-10-23 16:45:06 +00:00
2025-04-15 18:03:27 +00:00
type Props = {
2025-11-25 23:05:45 +00:00
pubkeys: string[]
2025-02-06 19:33:01 +00:00
info?: Snippet
2025-04-15 18:03:27 +00:00
}
2025-11-25 23:05:45 +00:00
const {pubkeys, info}: Props = $props()
2024-10-23 16:45:06 +00:00
2026-05-06 23:56:31 +00:00
const chatId = makeChatId(pubkeys)
const context = makeFeedContext({relays: $router.resolver.relays([userInbox()])})
onDestroy(context.cleanup)
const chat = $chats.one(chatId)
2026-07-28 16:16:26 +00:00
const others = remove($user.pubkey, pubkeys)
const messagingRelayLists = $app.use(MessagingRelayLists).index.$
const missingRelayLists = $derived(others.filter(pk => !$messagingRelayLists.has(pk)))
2024-10-23 16:45:06 +00:00
const showMembers = () =>
2025-11-13 21:25:34 +00:00
others.length === 1
? pushModal(ProfileDetail, {pubkey: others[0]})
: pushModal(ChatMembers, {pubkeys: others})
2024-10-23 16:45:06 +00:00
const back = () => navigate("/chat")
2026-03-30 21:24:40 +00:00
const replyTo = (event: TrustedEvent) => composeArea?.reply(event)
2024-10-23 16:45:06 +00:00
const onSubmit = async ({parent, edit, ...values}: ComposeValues) => {
const ptags = others.map(pk => ["p", pk])
2025-06-03 23:46:30 +00:00
// p tags would fork the conversation, so drop them before quoting the parent
const params = await prependParent(parent, {
content: values.content,
tags: values.tags.filter(nthNe(0, "p")),
})
2025-06-03 23:46:30 +00:00
if (edit) {
const command = await $deletes.deleteEvent(edit)
2026-07-28 16:16:26 +00:00
await $wraps.publish({event: command.event, recipients: pubkeys, pow: 16})
}
2025-06-03 23:46:30 +00:00
const [imetaTags, tags] = partition(nthEq(0, "imeta"), params.tags)
const imetas = imetaTags.map(tag => tag.slice(1).map(entry => entry.split(" ")))
const templates: EventTemplate[] = []
const buffer = []
2025-06-03 23:46:30 +00:00
const addTemplate = (kind: number, content: string, tags: string[][]) => {
content = content.trim()
2026-03-10 22:13:33 +00:00
if (content) {
templates.push(
makeEvent(kind, {
content,
tags: [...tags, ...ptags],
created_at: edit?.created_at,
2026-03-10 22:13:33 +00:00
}),
)
}
}
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.filter(nthNe(0, "url")))
} else {
buffer.push(p.raw)
}
2025-06-03 23:46:30 +00:00
}
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags)
const delay = await loadSendDelay()
// Kind 15 carries one image each per nip 17, and a second's sleep keeps the timestamps distinct.
const thunks = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) =>
$wraps.publish({
event,
recipients: pubkeys,
delay: delay + ms(i),
pow: 16,
}),
),
)
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: $app.use(Thunks).merge(thunks)},
},
})
2026-03-10 22:13:33 +00:00
}
2025-06-03 23:46:30 +00:00
2026-03-10 22:13:33 +00:00
const canEditEvent = (event: TrustedEvent) =>
2026-07-28 16:16:26 +00:00
event.pubkey === $user.pubkey &&
2026-03-10 22:13:33 +00:00
event.kind === DIRECT_MESSAGE &&
event.created_at >= ago(500, MINUTE)
const onEditEvent = (event: TrustedEvent) => composeArea?.edit(event)
2026-03-10 22:13:33 +00:00
2025-02-04 00:37:14 +00:00
let loading = $state(true)
let composeArea: ComposeArea | undefined = $state()
2024-10-23 16:45:06 +00:00
2025-02-04 00:37:14 +00:00
const elements = $derived.by(() => {
const elements = []
2024-10-23 16:45:06 +00:00
let previousDate
let previousPubkey
2024-10-30 20:52:35 +00:00
let previousCreatedAt = 0
2024-10-23 16:45:06 +00:00
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,
2024-10-23 16:45:06 +00:00
})
previousDate = date
previousPubkey = pubkey
2024-10-30 20:52:35 +00:00
previousCreatedAt = created_at
2024-10-23 16:45:06 +00:00
}
2025-02-04 00:37:14 +00:00
return elements.reverse()
})
2024-10-23 16:45:06 +00:00
2024-11-14 21:17:47 +00:00
onMount(() => {
2025-04-15 18:03:27 +00:00
for (const pubkey of others) {
2026-07-28 16:16:26 +00:00
$app.use(MessagingRelayLists).load(pubkey)
2025-04-15 18:03:27 +00:00
}
2024-10-23 16:45:06 +00:00
})
setTimeout(() => {
loading = false
2024-10-24 18:52:02 +00:00
}, 5000)
2024-10-23 16:45:06 +00:00
</script>
2025-05-05 17:11:02 +00:00
<PageBar>
2026-03-30 21:24:40 +00:00
<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">
2026-07-02 21:22:55 +00:00
<div class="truncate min-w-0 flex items-center gap-4 whitespace-nowrap">
2026-03-30 21:24:40 +00:00
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
<div class="flex items-center gap-2">
{#if others.length > 1}
2026-03-30 21:24:40 +00:00
<ProfileCircles pubkeys={others} size={5} />
{:else}
<ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ChatTitle {pubkeys} />
</p>
</div>
2026-03-30 21:24:40 +00:00
</Button>
</div>
</div>
</div>
2025-05-05 17:11:02 +00:00
</PageBar>
2025-03-04 18:20:06 +00:00
<div class="room flex min-h-0 min-w-0 flex-1 flex-col">
<PageContent class="flex flex-col-reverse gap-4 py-2 bg-surface !mb-0">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card flex flex-col gap-2 m-auto max-w-md items-center text-center">
<p class="flex gap-2 text-lg text-error">
<Icon icon={Danger} />
Direct messages are not enabled
</p>
<p>
Ask
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
2026-08-21 15:31:53 +00:00
to enable direct messaging by opening this conversation in their client.
</p>
</div>
2025-02-04 22:21:21 +00:00
</div>
2024-10-23 16:45:06 +00:00
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "date"}
<Divider>{value}</Divider>
2024-10-23 16:45:06 +00:00
{:else}
<ChatMessage
event={$state.snapshot(value as TrustedEvent)}
{pubkeys}
{showPubkey}
{context}
{replyTo}
canEdit={canEditEvent}
onEdit={onEditEvent} />
2024-10-23 16:45:06 +00:00
{/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="room__compose bg-surface px-2">
<ComposeArea
{onSubmit}
encryptFiles
draftKey="dm:{chatId}"
messages={$chat?.messages || []}
canEdit={canEditEvent}
disabled={Boolean(missingRelayLists.length)}
bind:this={composeArea} />
2024-10-23 16:45:06 +00:00
</div>
</div>