706 lines
22 KiB
Svelte
706 lines
22 KiB
Svelte
<script lang="ts">
|
|
import {onDestroy, onMount} from "svelte"
|
|
import {readable} from "svelte/store"
|
|
import {page} from "$app/stores"
|
|
import {goto} from "$app/navigation"
|
|
import type {Readable} from "svelte/store"
|
|
import {debounce} from "throttle-debounce"
|
|
import cx from "classnames"
|
|
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
|
import type {Maybe} from "@welshman/lib"
|
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
|
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
|
|
import {publish} from "@welshman/app"
|
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
|
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
|
import Close from "@assets/icons/close.svg?dataurl"
|
|
import {fade, fly} from "@lib/transition"
|
|
import {documentActive} from "@lib/html"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import Divider from "@lib/components/Divider.svelte"
|
|
import Icon from "@lib/components/Icon.svelte"
|
|
import Spinner from "@lib/components/Spinner.svelte"
|
|
import RoomCompose from "@app/components/RoomCompose.svelte"
|
|
import RoomComposeEdit from "@app/components/RoomComposeEdit.svelte"
|
|
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
|
import RoomItem from "@app/components/RoomItem.svelte"
|
|
import RoomItemAddMember from "@app/components/RoomItemAddMember.svelte"
|
|
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
|
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
|
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
|
import {deletes, relays, rooms, thunks, user} from "@app/core"
|
|
import {publishRoomJoinRequest} from "@app/access"
|
|
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
|
|
import {
|
|
PROTECTED,
|
|
RoomType,
|
|
deriveUserRoomMembershipStatus,
|
|
MembershipStatus,
|
|
getRoomType,
|
|
prependParent,
|
|
} from "@app/rooms"
|
|
import {userSettingsValues} from "@app/settings"
|
|
import {makeFeed} from "@app/feeds"
|
|
import {checked, deferredRoomPath, notifications, setChecked} from "@app/notifications"
|
|
import {makeRoomPath} from "@app/routes"
|
|
import {pendingShare, type Share} from "@app/share"
|
|
import {pushToast} from "@app/toast"
|
|
|
|
type Props = {
|
|
url: string
|
|
h?: string
|
|
}
|
|
|
|
const {url, h}: Props = $props()
|
|
|
|
const room = h ? $rooms.forRoom(url, h) : readable(undefined)
|
|
const addMemberKind = h ? ROOM_ADD_MEMBER : RELAY_ADD_MEMBER
|
|
const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice)
|
|
|
|
const voiceConnectedHere = $derived(
|
|
isVoiceRoom &&
|
|
$callState === CallState.Connected &&
|
|
$callTargetRoom?.url === url &&
|
|
$callTargetRoom?.h === h,
|
|
)
|
|
|
|
const showMobileVideoPanel = $derived(
|
|
isVoiceRoom && $callState === CallState.Connected && $videoCallLayout === VideoCallLayout.Video,
|
|
)
|
|
|
|
// Basic pass at issue #121's "chat overlaid on video" idea: float the chat panel over
|
|
// the full-bleed video instead of splitting the screen into fixed columns, so the call
|
|
// stays the visual focus. The panel opens from — and collapses back to — a chat FAB.
|
|
const isOverlayChat = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
|
|
|
|
const videoCallChatHidden = $derived(
|
|
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video,
|
|
)
|
|
|
|
const roomPath = h ? makeRoomPath(url, h) : undefined
|
|
const chatUnread = $derived(roomPath !== undefined && $notifications.has(roomPath))
|
|
|
|
const openChat = () => videoCallLayout.set(VideoCallLayout.Split)
|
|
const closeChat = () => videoCallLayout.set(VideoCallLayout.Video)
|
|
|
|
$effect(() => {
|
|
deferredRoomPath.set(videoCallChatHidden ? roomPath : undefined)
|
|
if (roomPath && voiceConnectedHere && !videoCallChatHidden) {
|
|
setChecked(roomPath)
|
|
}
|
|
// The messages pane (and its scroll position) is offscreen while the call view is
|
|
// showing — don't leave its floating "scroll down"/"new messages" chips stranded.
|
|
if (videoCallChatHidden) {
|
|
showScrollButton = false
|
|
showFixedNewMessages = false
|
|
}
|
|
})
|
|
|
|
onDestroy(() => deferredRoomPath.set(undefined))
|
|
|
|
let wasConnectedHere = $state(false)
|
|
|
|
$effect(() => {
|
|
if (!voiceConnectedHere) {
|
|
if ($callState !== CallState.Connected) {
|
|
videoCallLayout.set(VideoCallLayout.Chat)
|
|
}
|
|
wasConnectedHere = false
|
|
return
|
|
}
|
|
|
|
// Land on the call view (chat as a FAB) as soon as the call starts here, whether
|
|
// or not anyone's camera is on — chat is one tap away via the FAB.
|
|
if (!wasConnectedHere) {
|
|
videoCallLayout.set(VideoCallLayout.Video)
|
|
wasConnectedHere = true
|
|
}
|
|
})
|
|
|
|
const shouldProtect = $relays.hasNip(url, 70)
|
|
const membershipStatus = h
|
|
? deriveUserRoomMembershipStatus(url, h)
|
|
: readable(MembershipStatus.Granted)
|
|
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
|
const inviteCode = $derived($page.url.searchParams.get("code") || "")
|
|
|
|
const join = async () => {
|
|
if (h) {
|
|
joining = true
|
|
|
|
try {
|
|
const thunk = await (inviteCode
|
|
? publishRoomJoinRequest(url, h, inviteCode)
|
|
: $rooms.joinRoom(url, {h}).then(publish))
|
|
|
|
const message = await thunk.waitForError()
|
|
|
|
if (message && !message.startsWith("duplicate:")) {
|
|
return pushToast({theme: "error", message})
|
|
}
|
|
|
|
// Restart the feed now that we're a member
|
|
start()
|
|
} finally {
|
|
joining = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const leave = async () => {
|
|
if (h) {
|
|
leaving = true
|
|
try {
|
|
const thunk = await $rooms.leaveRoom(url, {h}).then(publish)
|
|
const message = await thunk.waitForError()
|
|
|
|
if (message && !message.startsWith("duplicate:")) {
|
|
pushToast({theme: "error", message})
|
|
}
|
|
} finally {
|
|
leaving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const replyTo = (event: TrustedEvent) => {
|
|
parent = event
|
|
compose?.focus()
|
|
}
|
|
|
|
const clearParent = () => {
|
|
parent = undefined
|
|
}
|
|
|
|
const clearShare = () => {
|
|
share = undefined
|
|
}
|
|
|
|
const clearEventToEdit = () => {
|
|
eventToEdit = undefined
|
|
}
|
|
|
|
const onSubmit = async ({content, tags}: EventContent) => {
|
|
if (!content && !sharedEvent) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (h) {
|
|
tags.push(["h", h])
|
|
}
|
|
|
|
const protect = await shouldProtect
|
|
|
|
if (protect) {
|
|
tags.push(PROTECTED)
|
|
}
|
|
|
|
let template: EventContent & {created_at?: number} = {content, tags}
|
|
|
|
if (eventToEdit) {
|
|
// Don't do anything if message hasn't changed
|
|
if (eventToEdit.content === content) {
|
|
return
|
|
}
|
|
|
|
// Delete previous message, to be republished with same timestamp
|
|
template.created_at = eventToEdit.created_at
|
|
|
|
const command = await $deletes.deleteEvent($state.snapshot(eventToEdit), w =>
|
|
w.setProtected(protect),
|
|
)
|
|
|
|
command.publishToRelays([url])
|
|
}
|
|
|
|
if (sharedEvent) {
|
|
template = await prependParent(sharedEvent, template, url)
|
|
}
|
|
|
|
if (parent) {
|
|
template = await prependParent(parent, template, url)
|
|
}
|
|
|
|
const thunk = $thunks.publish({
|
|
relays: [url],
|
|
event: makeEvent(MESSAGE, template),
|
|
delay: $userSettingsValues.send_delay,
|
|
})
|
|
|
|
if ($userSettingsValues.send_delay) {
|
|
pushToast({
|
|
timeout: 30_000,
|
|
children: {
|
|
component: ThunkToast,
|
|
props: {thunk},
|
|
},
|
|
})
|
|
}
|
|
} finally {
|
|
clearParent()
|
|
clearShare()
|
|
clearEventToEdit()
|
|
}
|
|
}
|
|
|
|
const manageScrollPosition = () => {
|
|
// Only treat an `at` jump as "scrolled up" when it targets an event below the
|
|
// newest one; jumping to the most recent message already lands us at the bottom.
|
|
const newestEvent = $events[$events.length - 1]
|
|
const atIsBelowNewest = !isNaN(at) && newestEvent !== undefined && at < newestEvent.created_at
|
|
|
|
showScrollButton = atIsBelowNewest || Math.abs(element?.scrollTop || 0) > 1500
|
|
|
|
const newMessages = document.getElementById("new-messages")
|
|
|
|
if (newMessagesSeen) {
|
|
showFixedNewMessages = false
|
|
} else if (newMessages) {
|
|
const {y} = newMessages.getBoundingClientRect()
|
|
|
|
if (y > 0 && y < 300) {
|
|
newMessagesSeen = true
|
|
showFixedNewMessages = false
|
|
} else {
|
|
showFixedNewMessages = y < 0
|
|
}
|
|
}
|
|
|
|
if (!userHasScrolled && !isNaN(at)) {
|
|
const targetEvent = $events.find(event => event.created_at >= at)
|
|
|
|
if (targetEvent) {
|
|
const target = element?.querySelector(`[data-event="${targetEvent.id}"]`)
|
|
|
|
if (target instanceof HTMLElement) {
|
|
isProgrammaticScroll = true
|
|
target.scrollIntoView({block: "center"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const onScroll = () => {
|
|
if (!isProgrammaticScroll) {
|
|
userHasScrolled = true
|
|
isUserScrolling = true
|
|
clearIsUserScrolling()
|
|
manageScrollPosition()
|
|
}
|
|
|
|
isProgrammaticScroll = false
|
|
}
|
|
|
|
const scrollToNewMessages = () =>
|
|
document.getElementById("new-messages")?.scrollIntoView({behavior: "smooth", block: "center"})
|
|
|
|
const scrollToBottom = () => {
|
|
if (!isNaN(at)) {
|
|
goto($page.url.pathname, {replaceState: true})
|
|
} else {
|
|
element?.scrollTo({top: 0, behavior: "smooth"})
|
|
}
|
|
}
|
|
|
|
// A tab can be `visible` but unfocused (user alt-tabbed to another app), so we
|
|
// can't rely on document.hidden alone to know the room is actually being watched.
|
|
const onActiveChange = (active: boolean) => {
|
|
if (!active) {
|
|
lastVisibleAt = now()
|
|
} else if ($events.some(e => e.pubkey !== $user.pubkey && e.created_at > lastVisibleAt)) {
|
|
newMessagesAfter = lastVisibleAt
|
|
newMessagesBefore = now()
|
|
newMessagesSeen = false
|
|
}
|
|
}
|
|
|
|
let joining = $state(false)
|
|
let leaving = $state(false)
|
|
let userHasScrolled = $state(false)
|
|
let isProgrammaticScroll = $state(false)
|
|
let isUserScrolling = $state(false)
|
|
let loadingBackward = $state(true)
|
|
let loadingForward = $state(true)
|
|
let share: Maybe<Share> = $state()
|
|
let parent: TrustedEvent | undefined = $state()
|
|
let element: HTMLElement | undefined = $state()
|
|
let lastVisibleAt = now()
|
|
let newMessagesAfter = $state($checked[$page.url.pathname])
|
|
let newMessagesBefore = $state(now())
|
|
let newMessagesSeen = false
|
|
let showFixedNewMessages = $state(false)
|
|
let showScrollButton = $state(false)
|
|
let cleanup: () => void
|
|
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
|
let compose: RoomCompose | undefined = $state()
|
|
let eventToEdit: TrustedEvent | undefined = $state()
|
|
|
|
// Claim the share once we're on screen. Sharing into the room 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 sharedEvent = $derived(share?.type === "event" ? share.value : undefined)
|
|
|
|
const initialValues = $derived.by((): Share | undefined => {
|
|
if (eventToEdit) {
|
|
return {type: "text", value: eventToEdit.content}
|
|
}
|
|
|
|
if (share) {
|
|
return share
|
|
}
|
|
})
|
|
|
|
const clearIsUserScrolling = debounce(150, () => {
|
|
isUserScrolling = false
|
|
})
|
|
|
|
const elements = $derived.by(() => {
|
|
const elements = []
|
|
const seen = new Set()
|
|
|
|
let previousDate
|
|
let previousKind
|
|
let previousPubkey
|
|
let previousCreatedAt = 0
|
|
let newMessagesSeen = false
|
|
|
|
if (events) {
|
|
const lastUserEvent = $events.findLast(e => e.pubkey === $user.pubkey)
|
|
|
|
// Adjust the boundary to account for messages that came from a different device
|
|
const adjustedAfter =
|
|
newMessagesAfter && lastUserEvent
|
|
? Math.max(lastUserEvent.created_at, newMessagesAfter)
|
|
: newMessagesAfter
|
|
|
|
for (const event of $events) {
|
|
if (seen.has(event.id)) {
|
|
continue
|
|
}
|
|
|
|
const date = formatTimestampAsDate(event.created_at)
|
|
|
|
if (
|
|
!newMessagesSeen &&
|
|
adjustedAfter &&
|
|
event.pubkey !== $user.pubkey &&
|
|
event.created_at > adjustedAfter &&
|
|
event.created_at < newMessagesBefore
|
|
) {
|
|
elements.push({type: "new-messages", id: "new-messages"})
|
|
newMessagesSeen = true
|
|
}
|
|
|
|
if (date !== previousDate) {
|
|
elements.push({type: "date", value: date, id: date, showPubkey: false})
|
|
}
|
|
|
|
const showPubkey =
|
|
previousPubkey !== event.pubkey ||
|
|
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
|
previousKind === addMemberKind
|
|
|
|
elements.push({
|
|
id: event.id,
|
|
type: "note",
|
|
value: event,
|
|
showPubkey,
|
|
})
|
|
|
|
previousDate = date
|
|
previousKind = event.kind
|
|
previousPubkey = event.pubkey
|
|
previousCreatedAt = event.created_at
|
|
seen.add(event.id)
|
|
}
|
|
}
|
|
|
|
elements.reverse()
|
|
|
|
return elements
|
|
})
|
|
|
|
$effect(() => {
|
|
if (elements.length > 0 && !isUserScrolling) {
|
|
requestAnimationFrame(manageScrollPosition)
|
|
}
|
|
})
|
|
|
|
const start = () => {
|
|
cleanup?.()
|
|
|
|
const feed = makeFeed({
|
|
relays: [url],
|
|
at: at || now(),
|
|
element: element!,
|
|
filters: [
|
|
h ? {kinds: [MESSAGE, addMemberKind], "#h": [h]} : {kinds: [MESSAGE, addMemberKind]},
|
|
],
|
|
onBackwardExhausted: () => {
|
|
loadingBackward = false
|
|
},
|
|
onForwardExhausted: () => {
|
|
loadingForward = false
|
|
},
|
|
})
|
|
|
|
events = feed.events
|
|
cleanup = feed.cleanup
|
|
}
|
|
|
|
const onEscape = () => {
|
|
clearParent()
|
|
clearShare()
|
|
eventToEdit = undefined
|
|
}
|
|
|
|
const canEditEvent = (event: TrustedEvent) =>
|
|
event.pubkey === $user.pubkey && event.created_at >= ago(5, MINUTE)
|
|
|
|
const onEditEvent = (event: TrustedEvent) => {
|
|
clearParent()
|
|
clearShare()
|
|
eventToEdit = event
|
|
}
|
|
|
|
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
|
|
|
onMount(() => {
|
|
// Defer rendering until navigation finishes
|
|
let frame = requestAnimationFrame(() => {
|
|
frame = requestAnimationFrame(start)
|
|
})
|
|
|
|
const unsubscribeActive = documentActive.subscribe(onActiveChange)
|
|
|
|
return () => {
|
|
cleanup?.()
|
|
unsubscribeActive()
|
|
cancelAnimationFrame(frame)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
|
|
{#if h && voiceConnectedHere}
|
|
<VideoCallContent
|
|
layout={$videoCallLayout}
|
|
{url}
|
|
{h}
|
|
class={cx(
|
|
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
|
|
isOverlayChat && "md:absolute md:inset-0",
|
|
)} />
|
|
{/if}
|
|
|
|
{#if h && isVoiceRoom && $callState === CallState.Connected}
|
|
<VideoCallContent
|
|
layout={$videoCallLayout}
|
|
mobile
|
|
{url}
|
|
{h}
|
|
class={cx("md:hidden", isOverlayChat && "absolute inset-0")} />
|
|
{/if}
|
|
|
|
<div
|
|
class={cx(
|
|
"room flex min-h-0 min-w-0 flex-1 flex-col",
|
|
videoCallChatHidden && "hidden",
|
|
isOverlayChat &&
|
|
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
|
|
)}>
|
|
{#if isOverlayChat}
|
|
<Button
|
|
aria-label="Close room chat"
|
|
class="button button-neutral button-xs button-circle absolute right-2 top-2 z-popover"
|
|
onclick={closeChat}>
|
|
<Icon icon={Close} size={4} />
|
|
</Button>
|
|
{/if}
|
|
|
|
{#if h}
|
|
<RoomPinnedMessages {url} {h} />
|
|
{/if}
|
|
|
|
<div
|
|
bind:this={element}
|
|
onscroll={onScroll}
|
|
class={cx(
|
|
"room__content scroll-container",
|
|
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
|
|
)}>
|
|
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
|
<div class="py-20">
|
|
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
|
|
<p class="opacity-75">You aren't currently a member of this room.</p>
|
|
{#if $membershipStatus === MembershipStatus.Pending}
|
|
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
|
|
<Icon icon={ClockCircle} />
|
|
Access Pending
|
|
</Button>
|
|
{:else}
|
|
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
|
|
{#if joining}
|
|
<Spinner size="sm" />
|
|
{:else}
|
|
<Icon icon={Login2} />
|
|
{/if}
|
|
Join Room
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{#if loadingForward && elements.length > 0}
|
|
<p class="py-20 flex justify-center">
|
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
|
</p>
|
|
{/if}
|
|
{#each elements as { type, id, value, showPubkey } (id)}
|
|
{#if type === "new-messages"}
|
|
<div
|
|
{id}
|
|
class={cx("flex items-center py-2 text-xs transition-colors", {
|
|
"opacity-0": showFixedNewMessages,
|
|
})}>
|
|
<div class="h-px grow bg-primary text-primary-content"></div>
|
|
<p
|
|
class="rounded-full bg-primary text-primary-content px-2 py-1"
|
|
style="color: var(--primary-content)">
|
|
New Messages
|
|
</p>
|
|
<div class="h-px grow bg-primary text-primary-content"></div>
|
|
</div>
|
|
{:else if type === "date"}
|
|
<Divider>{value}</Divider>
|
|
{:else}
|
|
{@const event = value as TrustedEvent}
|
|
{#if event.kind === addMemberKind}
|
|
<RoomItemAddMember {url} {event} />
|
|
{:else}
|
|
<RoomItem
|
|
{url}
|
|
{event}
|
|
{replyTo}
|
|
{showPubkey}
|
|
canEdit={canEditEvent}
|
|
onEdit={onEditEvent} />
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
<p class="flex h-10 items-center justify-center py-20">
|
|
{#if loadingBackward}
|
|
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
|
|
{:else}
|
|
<Spinner>End of message history</Spinner>
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
<div class="h-screen"></div>
|
|
</div>
|
|
|
|
<div
|
|
class={cx(
|
|
"room__compose flex flex-col gap-1 md:flex-row md:gap-0",
|
|
showMobileVideoPanel && "max-md:hidden",
|
|
)}>
|
|
<div class="room__compose-inner min-w-0 flex-1">
|
|
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
|
|
<!-- pass -->
|
|
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
|
|
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3">
|
|
<p class="opacity-75">Only members are allowed to post to this room.</p>
|
|
{#if $membershipStatus === MembershipStatus.Pending}
|
|
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
|
|
<Icon icon={ClockCircle} />
|
|
Access Pending
|
|
</Button>
|
|
{:else}
|
|
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
|
|
{#if joining}
|
|
<Spinner size="sm" />
|
|
{:else}
|
|
<Icon icon={Login2} />
|
|
{/if}
|
|
Ask to Join
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
{#if parent}
|
|
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
|
{/if}
|
|
{#if sharedEvent}
|
|
<RoomComposeParent event={sharedEvent} clear={clearShare} verb="Sharing" />
|
|
{/if}
|
|
{#if eventToEdit}
|
|
<RoomComposeEdit clear={clearEventToEdit} />
|
|
{/if}
|
|
</div>
|
|
{#key initialValues}
|
|
<RoomCompose
|
|
{url}
|
|
{h}
|
|
{onSubmit}
|
|
{onEscape}
|
|
{onEditPrevious}
|
|
{initialValues}
|
|
bind:this={compose} />
|
|
{/key}
|
|
{/if}
|
|
</div>
|
|
{#if isVoiceRoom || $callState === CallState.Joining || $callState === CallState.Connected}
|
|
<div
|
|
class={cx(
|
|
"hide-on-keyboard flex-shrink-0 p-2 md:hidden",
|
|
videoCallChatHidden && "hidden",
|
|
)}>
|
|
<VoiceWidget />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if videoCallChatHidden}
|
|
<Button
|
|
aria-label="Open room chat"
|
|
class="button button-primary button-circle fixed z-popover bottom-[calc(4.5rem+var(--saib))] right-4 shadow-lg md:bottom-4"
|
|
onclick={openChat}>
|
|
<Icon icon={ChatRound} size={5} />
|
|
{#if chatUnread}
|
|
<span
|
|
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
|
|
aria-hidden="true"></span>
|
|
{/if}
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if showScrollButton}
|
|
<div in:fade class="chat__scroll-down">
|
|
<Button class="button button-neutral button-circle" onclick={scrollToBottom}>
|
|
<Icon icon={AltArrowDown} />
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showFixedNewMessages}
|
|
<div class="relative z-popover flex justify-center">
|
|
<div transition:fly={{duration: 200}} class="fixed top-12 pt-sai">
|
|
<Button class="button button-primary button-xs button-pill" onclick={scrollToNewMessages}>
|
|
New Messages
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|