flotilla/src/app/components/Chat.svelte

361 lines
11 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,
2026-03-10 22:13:33 +00:00
ifLet,
2025-06-03 23:46:30 +00:00
spec,
nthEq,
nthNe,
MINUTE,
sortBy,
remove,
enumerate,
2025-06-03 23:46:30 +00:00
formatTimestampAsDate,
} from "@welshman/lib"
2026-08-11 21:54:05 +00:00
import type {Maybe} from "@welshman/lib"
2025-06-03 23:46:30 +00:00
import type {TrustedEvent, EventTemplate, EventContent} 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"
import ProfileName from "@app/components/ProfileName.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"
2024-10-23 16:45:06 +00:00
import ChatMessage from "@app/components/ChatMessage.svelte"
2025-05-05 17:11:02 +00:00
import ChatCompose from "@app/components/ChatCompose.svelte"
import ComposeEdit from "@app/components/ComposeEdit.svelte"
import ComposeParent from "@app/components/ComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import {app, deletes, router, user, wraps} from "@app/core"
import {userSettingsValues} from "@app/settings"
import {deriveChat, makeChatId} from "@app/chats"
import {makeFeedContext} from "@app/feeds"
import {navigate, pushModal} from "@app/modal"
2026-08-11 21:54:05 +00:00
import {DraftKey, type Draft} from "@app/drafts"
2026-07-28 16:16:26 +00:00
import {prependParent} from "@app/rooms"
2026-08-11 21:54:05 +00:00
import {pendingShare, type Share} from "@app/share"
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)
2026-05-06 23:56:31 +00:00
const chat = deriveChat(chatId)
2026-08-11 21:54:05 +00:00
const draftKey = new DraftKey<Draft>(`dm:${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) => {
parent = event
2025-02-04 00:37:14 +00:00
compose?.focus()
}
const clearParent = () => {
parent = undefined
}
2024-10-23 16:45:06 +00:00
2026-03-10 22:13:33 +00:00
const clearEventToEdit = () => {
eventToEdit = undefined
}
2025-02-03 16:59:45 +00:00
const onSubmit = async (params: EventContent) => {
const ptags = others.map(pk => ["p", pk])
2025-06-03 23:46:30 +00:00
// Remove p tags since they result in forking the conversation
params.tags = params.tags.filter(nthNe(0, "p"))
2025-02-03 16:59:45 +00:00
// Add our reply quote to content
params = await prependParent(parent, params)
2025-06-03 23:46:30 +00:00
if (eventToEdit) {
// An edit that changes nothing is just a dismissal
if (eventToEdit.content === params.content) {
return clearEventToEdit()
}
2025-06-03 23:46:30 +00:00
const command = await $deletes.deleteEvent(eventToEdit)
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: eventToEdit?.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)
// 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]) =>
$wraps.publish({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
pow: 16,
}),
),
)
// Only once the message exists. Publishing has to read each recipient's messaging relays first,
// and a failed read throws before any thunk is made — so the reply or edit this was part of has
// to survive that along with the draft the composer is holding on to.
clearParent()
clearEventToEdit()
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 onEscape = () => {
clearParent()
clearEventToEdit()
}
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)
2026-03-10 22:13:33 +00:00
const onEditEvent = (event: TrustedEvent) => {
clearParent()
2026-03-10 22:13:33 +00:00
eventToEdit = event
2024-10-23 16:45:06 +00:00
}
2026-08-21 15:31:53 +00:00
const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent)
2026-03-10 22:13:33 +00:00
2025-02-04 00:37:14 +00:00
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
2026-03-10 22:13:33 +00:00
let eventToEdit: TrustedEvent | undefined = $state()
2026-08-11 21:54:05 +00:00
let share: Maybe<Share> = $state()
// Claim the share once we're on screen. Sharing into the conversation you're already looking at
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => {
if ($pendingShare) {
share = $pendingShare
pendingShare.set(undefined)
}
})
const initialValues = $derived.by((): Share | undefined => {
if (eventToEdit) {
return {type: "text", value: eventToEdit.content}
}
if (share) {
return share
}
})
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}>
{#if others.length === 0}
<div class="flex gap-2">
2026-07-28 16:16:26 +00:00
<ProfileCircle pubkey={$user.pubkey} size={5} />
<ProfileName pubkey={$user.pubkey} />
2026-03-30 21:24:40 +00:00
</div>
{:else if others.length === 1}
<div class="flex gap-2">
2026-03-30 21:24:40 +00:00
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
2026-03-30 21:24:40 +00:00
</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>
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">
<div class="flex flex-col gap-px">
{#if parent}
<ComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if eventToEdit}
<ComposeEdit clear={clearEventToEdit} />
{/if}
</div>
2026-08-11 21:54:05 +00:00
{#key initialValues}
<ChatCompose
bind:this={compose}
{onSubmit}
{onEscape}
{onEditPrevious}
2026-08-11 21:54:05 +00:00
{initialValues}
draftKey={eventToEdit ? undefined : draftKey}
disabled={Boolean(missingRelayLists.length)} />
{/key}
2024-10-23 16:45:06 +00:00
</div>
</div>