flotilla/src/app/components/Chat.svelte

316 lines
9.3 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"
2024-10-23 16:45:06 +00:00
import {onMount} from "svelte"
2025-06-03 23:46:30 +00:00
import {
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, EventContent} from "@welshman/util"
import {parse, isLink} from "@welshman/content"
import {
2025-06-10 20:35:57 +00:00
makeEvent,
2025-06-03 23:46:30 +00:00
tagsFromIMeta,
getTags,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE,
} from "@welshman/util"
2025-02-03 16:59:45 +00:00
import {
pubkey,
tagPubkey,
2025-05-05 17:11:02 +00:00
sendWrapped,
mergeThunks,
2025-11-20 23:12:16 +00:00
loadMessagingRelayList,
messagingRelayListsByPubkey,
2025-02-03 16:59:45 +00:00
} from "@welshman/app"
2025-09-10 22:26:39 +00:00
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
2024-10-23 16:45:06 +00:00
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageBar from "@lib/components/PageBar.svelte"
2025-03-19 17:30:36 +00:00
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"
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 ChatComposeParent from "@app/components/ChatComposeParent.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
2025-04-15 22:45:48 +00:00
import {
INDEXER_RELAYS,
userSettingsValues,
2025-04-15 22:45:48 +00:00
deriveChat,
splitChatId,
PLATFORM_NAME,
} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {prependParent} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
2024-10-23 16:45:06 +00:00
2025-04-15 18:03:27 +00:00
type Props = {
2025-02-06 19:33:01 +00:00
id: string
info?: Snippet
2025-04-15 18:03:27 +00:00
}
const {id, info}: Props = $props()
2024-10-23 16:45:06 +00:00
const chat = deriveChat(id)
const pubkeys = splitChatId(id)
const others = remove($pubkey!, pubkeys)
2025-11-20 23:12:16 +00:00
const missingRelayLists = $derived(pubkeys.filter(pk => !$messagingRelayListsByPubkey.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 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
2025-02-03 16:59:45 +00:00
const onSubmit = async (params: EventContent) => {
2025-06-03 23:46:30 +00:00
const ptags = remove($pubkey!, pubkeys).map(tagPubkey)
2025-02-03 16:59:45 +00:00
// Remove p tags since they result in forking the conversation
2025-06-03 23:46:30 +00:00
params.tags = params.tags.filter(nthNe(0, "p"))
2025-02-03 16:59:45 +00:00
2025-06-03 23:46:30 +00:00
// Add our reply quote to content
params = prependParent(parent, params)
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) {
2025-06-10 20:35:57 +00:00
templates.push(makeEvent(kind, {content, tags: [...tags, ...ptags]}))
2025-06-03 23:46:30 +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.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 = Array.from(enumerate(templates)).map(([i, event]) =>
sendWrapped({
event,
recipients: pubkeys,
delay: $userSettingsValues.send_delay + ms(i),
}),
)
pushToast({
timeout: 30_000,
children: {
component: ThunkToast,
props: {thunk: mergeThunks(thunks)},
},
})
clearParent()
2024-10-23 16:45:06 +00:00
}
2025-02-04 00:37:14 +00:00
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
let parent: TrustedEvent | undefined = $state()
2025-03-04 20:53:38 +00:00
let chatCompose: HTMLElement | undefined = $state()
2025-11-20 23:12:16 +00:00
let dlists: HTMLElelist | 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) {
2025-11-20 23:12:16 +00:00
loadMessagingRelayList(pubkey, INDEXER_RELAYS, true)
2025-04-15 18:03:27 +00:00
}
2025-03-04 18:20:06 +00:00
const observer = new ResizeObserver(() => {
2025-04-11 16:27:19 +00:00
if (dynamicPadding && chatCompose) {
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
}
2025-03-04 18:20:06 +00:00
})
2025-03-04 20:53:38 +00:00
observer.observe(chatCompose!)
2025-05-08 18:05:27 +00:00
observer.observe(dynamicPadding!)
2025-03-04 18:20:06 +00:00
return () => {
2025-03-04 20:53:38 +00:00
observer.unobserve(chatCompose!)
2025-05-08 18:05:27 +00:00
observer.unobserve(dynamicPadding!)
2025-03-04 18:20:06 +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>
{#snippet title()}
2025-11-13 21:25:34 +00:00
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
2025-05-05 17:11:02 +00:00
{#if others.length === 0}
<div class="row-2">
<ProfileCircle pubkey={$pubkey!} size={5} />
<ProfileName pubkey={$pubkey!} />
</div>
{:else if others.length === 1}
2025-11-13 21:25:34 +00:00
<div class="row-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
2025-05-05 17:11:02 +00:00
{: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}
2025-11-13 21:25:34 +00:00
</Button>
2025-05-05 17:11:02 +00:00
{/snippet}
{#snippet action()}
2025-11-20 23:12:16 +00:00
{#if remove($pubkey, missingRelayLists).length > 0}
{@const count = remove($pubkey, missingRelayLists).length}
{@const label = count > 1 ? "lists are" : "list is"}
2025-11-13 21:25:34 +00:00
<div
class="row-2 badge badge-error badge-lg tooltip tooltip-left cursor-pointer"
2025-11-20 23:12:16 +00:00
data-tip="{count} messaging {label} not configured.">
2025-11-13 21:25:34 +00:00
<Icon icon={Danger} />
{count}
</div>
{/if}
2025-05-05 17:11:02 +00:00
{/snippet}
</PageBar>
2025-03-04 18:20:06 +00:00
2025-06-03 23:46:30 +00:00
<PageContent class="flex flex-col-reverse gap-2 pt-4">
2025-03-04 18:20:06 +00:00
<div bind:this={dynamicPadding}></div>
2025-11-20 23:12:16 +00:00
{#if missingRelayLists.includes($pubkey!)}
2025-03-04 18:20:06 +00:00
<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">
2025-09-10 22:26:39 +00:00
<Icon icon={Danger} />
2025-11-20 23:12:16 +00:00
Your messaging relays are not configured.
2025-03-04 18:20:06 +00:00
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please visit
2025-11-20 23:12:16 +00:00
your <Link class="link" href="/settings/relays">relay settings page</Link> to receive messages.
2025-03-04 18:20:06 +00:00
</p>
</div>
</div>
2025-11-20 23:12:16 +00:00
{:else if missingRelayLists.length > 0}
2025-03-04 18:20:06 +00:00
<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">
2025-09-10 22:26:39 +00:00
<Icon icon={Danger} />
2025-11-20 23:12:16 +00:00
{missingRelayLists.length} messaging
{missingRelayLists.length > 1 ? "lists are" : "list is"} not configured.
2025-03-04 18:20:06 +00:00
</p>
<p>
In order to deliver messages, {PLATFORM_NAME} needs to know where to send them. Please make
2025-11-20 23:12:16 +00:00
sure everyone in this conversation has set up their messaging relays.
2025-03-04 18:20:06 +00:00
</p>
2025-02-04 22:21:21 +00:00
</div>
2025-03-04 18:20:06 +00:00
</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} />
2024-10-23 16:45:06 +00:00
{/if}
2025-03-04 18:20:06 +00:00
{/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...
2024-10-23 16:45:06 +00:00
{:else}
2025-03-04 18:20:06 +00:00
End of message history
2024-10-23 16:45:06 +00:00
{/if}
2025-03-04 18:20:06 +00:00
</Spinner>
{@render info?.()}
</p>
2025-03-19 17:30:36 +00:00
</PageContent>
2025-03-04 18:20:06 +00:00
2025-03-04 20:53:38 +00:00
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
2025-03-04 18:20:06 +00:00
{#if parent}
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
2024-10-23 16:45:06 +00:00
</div>
<ChatCompose bind:this={compose} {onSubmit} />
2024-10-23 16:45:06 +00:00
</div>