Files
flotilla/src/app/components/RoomForm.svelte
T
2026-03-04 10:28:34 -05:00

246 lines
8.1 KiB
Svelte

<script lang="ts">
import type {Snippet} from "svelte"
import type {RoomMeta} from "@welshman/util"
import {makeRoomMeta} from "@welshman/util"
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import IconPickerButton from "@lib/components/IconPickerButton.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands"
import {checkRelayHasLivekit} from "@app/voice"
type RoomMode = "text" | "voice" | "both"
const getRoomModeFromEvent = (event?: {tags?: string[][]}): RoomMode => {
const tags = event?.tags ?? []
const hasLivekit = tags.some(t => t[0] === "livekit")
const hasNoText = tags.some(t => t[0] === "no-text")
if (hasLivekit && hasNoText) return "voice"
if (hasLivekit) return "both"
return "text"
}
const buildTagsWithRoomMode = (existingTags: string[][], roomMode: RoomMode): string[][] => {
const filtered = existingTags.filter(t => t[0] !== "livekit" && t[0] !== "no-text")
if (roomMode === "both") return [...filtered, ["livekit"]]
if (roomMode === "voice") return [...filtered, ["livekit"], ["no-text"]]
return filtered
}
type Props = {
url: string
header: Snippet
footer: Snippet<[{loading: boolean}]>
onsubmit: (room: RoomMeta) => void
initialValues?: RoomMeta
}
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues)
let roomMode = $state<RoomMode>(getRoomModeFromEvent(initialValues.event))
let relayHasLivekit = $state<boolean | undefined>(undefined)
$effect(() => {
const u = url
let cancelled = false
checkRelayHasLivekit(u).then(has => {
if (!cancelled) relayHasLivekit = has
})
return () => {
cancelled = true
}
})
const submit = async () => {
const room = $state.snapshot(values)
if ((roomMode === "voice" || roomMode === "both") && !relayHasLivekit) {
return pushToast({
theme: "error",
message: "This relay does not support voice rooms.",
})
}
if (imageFile) {
const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) {
return pushToast({theme: "error", message: error})
}
room.picture = result.url
room.pictureMeta = result.tags
}
const existingTags = room.event?.tags ?? []
const tags = buildTagsWithRoomMode(existingTags, roomMode)
room.event = room.event ? {...room.event, tags} : ({tags} as RoomMeta["event"])
const createMessage = await waitForThunkError(createRoom(url, room))
if (createMessage && !createMessage.includes("already")) {
return pushToast({theme: "error", message: createMessage})
}
const editMessage = await waitForThunkError(editRoom(url, room))
if (editMessage) {
return pushToast({theme: "error", message: editMessage})
}
const joinMessage = await waitForThunkError(joinRoom(url, room))
if (joinMessage && !joinMessage.includes("already")) {
return pushToast({theme: "error", message: joinMessage})
}
onsubmit(room)
}
const trySubmit = async () => {
loading = true
try {
await submit()
} finally {
loading = false
}
}
let loading = $state(false)
let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture)
const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (file && file.type.startsWith("image/")) {
const reader = new FileReader()
reader.onload = e => {
imageFile = file
imagePreview = e.target?.result as string
}
reader.readAsDataURL(file)
}
}
const handleIconSelect = (iconUrl: string) => {
imagePreview = iconUrl
const parts = iconUrl.split(",")
const imageData = atob(parts[1])
const result = new Uint8Array(imageData.length)
for (let n = 0; n < imageData.length; n++) {
result[n] = imageData.charCodeAt(n)
}
imageFile = new File([result], `icon.svg`, {type: "image/svg+xml"})
}
</script>
<Modal tag="form" onsubmit={preventDefault(trySubmit)}>
<ModalBody>
{@render header()}
<FieldInline>
{#snippet label()}
<p>Icon</p>
{/snippet}
{#snippet input()}
<div class="flex flex-grow items-center justify-between gap-4">
{#if imagePreview}
<div class="flex items-center gap-2">
<span class="text-sm opacity-75">Selected:</span>
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
</div>
{:else}
<span class="text-sm opacity-75">No icon selected</span>
{/if}
<div class="flex gap-2">
<IconPickerButton onSelect={handleIconSelect} class="btn btn-primary btn-sm">
<Icon icon={StickerSmileSquare} size={4} />
</IconPickerButton>
<label class="btn btn-neutral btn-sm cursor-pointer">
<Icon icon={UploadMinimalistic} size={4} />
<input type="file" accept="image/*" class="hidden" onchange={handleImageUpload} />
</label>
</div>
</div>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Name</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
{#if imagePreview}
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else}
<Icon icon={Hashtag} />
{/if}
<input bind:value={values.name} class="grow" type="text" />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Description</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={values.about} class="grow" type="text" />
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Room type</p>
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full" bind:value={roomMode} aria-label="Room type">
<option value="text">Text only</option>
<option value="both" disabled={relayHasLivekit === false}>
Text and voice{relayHasLivekit === false ? " (not setup)" : ""}
</option>
<option value="voice" disabled={relayHasLivekit === false}>
Voice only{relayHasLivekit === false ? " (not setup)" : ""}
</option>
</select>
{/snippet}
</FieldInline>
<strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
<span class="text-sm opacity-75">Only allow members to send messages</span>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isPrivate} />
<span class="text-sm opacity-75">Only allow members to read messages</span>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isHidden} />
<span class="text-sm opacity-75">Hide this group from non-members</span>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} />
<span class="text-sm opacity-75">Ignore requests to join</span>
</div>
</ModalBody>
{@render footer({loading})}
</Modal>