forked from coracle/flotilla
Compare commits
13 Commits
dev
..
video-demo
| Author | SHA1 | Date | |
|---|---|---|---|
| ea4e1cde31 | |||
| 4f2e494959 | |||
| fef449be85 | |||
| 945e853e3b | |||
| bad96500d5 | |||
| 148286dc04 | |||
| 3decff3cfc | |||
| b4b8f85e18 | |||
| 6cc21de400 | |||
| 39e851b735 | |||
| 81ff1cafdc | |||
| 008dd246ef | |||
| 50ccfa775f |
+67
-12
@@ -50,6 +50,7 @@
|
|||||||
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
--saib: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
|
||||||
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
--sail: var(--safe-area-inset-left, env(safe-area-inset-left));
|
||||||
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
--sair: var(--safe-area-inset-right, env(safe-area-inset-right));
|
||||||
|
--video-call-panel-bg: #181e24;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme] {
|
[data-theme] {
|
||||||
@@ -364,14 +365,6 @@
|
|||||||
|
|
||||||
/* tippy popover */
|
/* tippy popover */
|
||||||
|
|
||||||
.tippy-target {
|
|
||||||
@apply pointer-events-none fixed inset-0 z-tooltip;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tippy-target > * {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tippy-box {
|
.tippy-box {
|
||||||
@apply rounded-box shadow-xl;
|
@apply rounded-box shadow-xl;
|
||||||
}
|
}
|
||||||
@@ -398,12 +391,57 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.left-content {
|
.cw {
|
||||||
@apply md:left-[calc(18.5rem+var(--sail))];
|
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-video-call-content {
|
||||||
|
@apply w-full md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice: desktop split — plain CSS so / in calc is not parsed as Tailwind slash syntax */
|
||||||
|
.cw-split-video {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-split-chat {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cw-split-video {
|
||||||
|
left: 18.5rem;
|
||||||
|
right: auto;
|
||||||
|
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-split-chat {
|
||||||
|
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
|
||||||
|
right: auto;
|
||||||
|
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cw-full {
|
||||||
|
@apply w-full md:left-[4rem] md:w-[calc(100%-4rem-var(--sair))];
|
||||||
|
}
|
||||||
|
|
||||||
|
.cb {
|
||||||
|
@apply md:bottom-sai bottom-[calc(var(--saib)+3.5rem)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.ct {
|
||||||
|
@apply top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard open state adjustments */
|
/* Keyboard open state adjustments */
|
||||||
|
|
||||||
|
body.keyboard-open .cb {
|
||||||
|
@apply bottom-sai;
|
||||||
|
}
|
||||||
|
|
||||||
body.keyboard-open .hide-on-keyboard {
|
body.keyboard-open .hide-on-keyboard {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -411,13 +449,30 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply relative z-compose mb-14 flex-grow md:mb-0;
|
@apply cb cw fixed z-compose;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose .chat__compose-inner {
|
.chat__compose-zone {
|
||||||
|
@apply cb cw fixed z-compose;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat__compose-zone .chat__compose-inner {
|
||||||
@apply min-w-0;
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat__compose-zone.cw-video-call-content {
|
||||||
|
@apply md:left-[calc(18.5rem+18rem)] md:w-[calc(100%-18.5rem-18rem-var(--sair))];
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.chat__compose-zone.cw-split-chat {
|
||||||
|
left: calc(18.5rem + (100vw - 18.5rem - var(--sair)) / 2);
|
||||||
|
right: auto;
|
||||||
|
width: calc((100vw - 18.5rem - var(--sair)) / 2);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat__scroll-down {
|
.chat__scroll-down {
|
||||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,8 @@
|
|||||||
let compose: ChatCompose | undefined = $state()
|
let compose: ChatCompose | undefined = $state()
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let eventToEdit: TrustedEvent | undefined = $state()
|
let eventToEdit: TrustedEvent | undefined = $state()
|
||||||
|
let chatCompose: HTMLElement | undefined = $state()
|
||||||
|
let dynamicPadding: HTMLElement | undefined = $state()
|
||||||
|
|
||||||
const elements = $derived.by(() => {
|
const elements = $derived.by(() => {
|
||||||
const elements = []
|
const elements = []
|
||||||
@@ -231,6 +233,20 @@
|
|||||||
for (const pubkey of others) {
|
for (const pubkey of others) {
|
||||||
loadMessagingRelayList(pubkey)
|
loadMessagingRelayList(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (dynamicPadding && chatCompose) {
|
||||||
|
dynamicPadding.style.minHeight = `${chatCompose.offsetHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(chatCompose!)
|
||||||
|
observer.observe(dynamicPadding!)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(chatCompose!)
|
||||||
|
observer.unobserve(dynamicPadding!)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -278,6 +294,7 @@
|
|||||||
</PageBar>
|
</PageBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
<PageContent class="flex flex-col-reverse gap-2 pt-4">
|
||||||
|
<div bind:this={dynamicPadding}></div>
|
||||||
{#if missingRelayLists.length > 0}
|
{#if missingRelayLists.length > 0}
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
<div class="card2 col-2 m-auto max-w-md items-center text-center">
|
||||||
@@ -318,10 +335,9 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
{@render info?.()}
|
{@render info?.()}
|
||||||
</p>
|
</p>
|
||||||
<div class="h-screen"></div>
|
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200">
|
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
|
||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
@@ -12,7 +11,6 @@
|
|||||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
|
||||||
import GoalCreate from "@app/components/GoalCreate.svelte"
|
import GoalCreate from "@app/components/GoalCreate.svelte"
|
||||||
import PollCreate from "@app/components/PollCreate.svelte"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
@@ -30,8 +28,6 @@
|
|||||||
|
|
||||||
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
const createClassified = () => pushModal(ClassifiedCreate, {url, h})
|
||||||
|
|
||||||
const createPoll = () => pushModal(PollCreate, {url, h})
|
|
||||||
|
|
||||||
let ul: Element
|
let ul: Element
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -64,10 +60,4 @@
|
|||||||
Create Thread
|
Create Thread
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Button onclick={createPoll}>
|
|
||||||
<Icon size={4} icon={Revote} />
|
|
||||||
Ask a Question
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -64,11 +64,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={spacer}></div>
|
<div bind:this={spacer}></div>
|
||||||
<form
|
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
|
||||||
in:fly
|
|
||||||
bind:this={form}
|
|
||||||
onsubmit={preventDefault(submit)}
|
|
||||||
class="left-content bottom-sai right-sai ml-2 pl-2 fixed z-feature">
|
|
||||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="note-editor flex-grow overflow-hidden">
|
<div class="note-editor flex-grow overflow-hidden">
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
|
||||||
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
import NoteContentThread from "@app/components/NoteContentThread.svelte"
|
||||||
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
import NoteContentClassified from "@app/components/NoteContentClassified.svelte"
|
||||||
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
|
||||||
import NoteContentPoll from "@app/components/NoteContentPoll.svelte"
|
|
||||||
import Content from "@app/components/Content.svelte"
|
import Content from "@app/components/Content.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
const props: ComponentProps<typeof Content> = $props()
|
||||||
@@ -21,8 +19,6 @@
|
|||||||
<NoteContentClassified {...props} />
|
<NoteContentClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentGoal {...props} />
|
<NoteContentGoal {...props} />
|
||||||
{:else if props.event.kind === Poll}
|
|
||||||
<NoteContentPoll {...props} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<Content {...props} />
|
<Content {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {ComponentProps} from "svelte"
|
import type {ComponentProps} from "svelte"
|
||||||
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
|
||||||
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
|
||||||
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
import NoteContentMinimalClassified from "@app/components/NoteContentMinimalClassified.svelte"
|
||||||
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
|
||||||
import NoteContentMinimalPoll from "@app/components/NoteContentMinimalPoll.svelte"
|
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
const props: ComponentProps<typeof ContentMinimal> = $props()
|
||||||
@@ -21,8 +19,6 @@
|
|||||||
<NoteContentMinimalClassified {...props} />
|
<NoteContentMinimalClassified {...props} />
|
||||||
{:else if props.event.kind === ZAP_GOAL}
|
{:else if props.event.kind === ZAP_GOAL}
|
||||||
<NoteContentMinimalGoal {...props} />
|
<NoteContentMinimalGoal {...props} />
|
||||||
{:else if props.event.kind === Poll}
|
|
||||||
<NoteContentMinimalPoll {...props} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<ContentMinimal {...props} />
|
<ContentMinimal {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {ComponentProps} from "svelte"
|
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {PollResponse} from "nostr-tools/kinds"
|
|
||||||
import ContentMinimal from "@app/components/ContentMinimal.svelte"
|
|
||||||
import {deriveEvents} from "@app/core/state"
|
|
||||||
import {getPollResults} from "@app/util/polls"
|
|
||||||
|
|
||||||
const props: ComponentProps<typeof ContentMinimal> = $props()
|
|
||||||
|
|
||||||
const responses = deriveEvents([{kinds: [PollResponse], "#e": [props.event.id]}])
|
|
||||||
|
|
||||||
const results = derived(responses, $responses => getPollResults(props.event, $responses))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-0">
|
|
||||||
<ContentMinimal {...props} />
|
|
||||||
<span class="text-xs opacity-50">{$results.voters} voter{$results.voters === 1 ? "" : "s"}</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {ComponentProps} from "svelte"
|
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {PollResponse} from "nostr-tools/kinds"
|
|
||||||
import PollVotes from "@app/components/PollVotes.svelte"
|
|
||||||
import Content from "@app/components/Content.svelte"
|
|
||||||
|
|
||||||
const props: ComponentProps<typeof Content> = $props()
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!props.url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
request({
|
|
||||||
relays: [props.url],
|
|
||||||
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<Content event={props.event} showEntire url={props.url} />
|
|
||||||
|
|
||||||
{#if props.url}
|
|
||||||
<PollVotes url={props.url} event={props.event} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
|
|
||||||
import {makeEvent} from "@welshman/util"
|
|
||||||
import {publishThunk} from "@welshman/app"
|
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
|
||||||
import HamburgerMenu from "@assets/icons/hamburger-menu.svg?dataurl"
|
|
||||||
import PlusCircle from "@assets/icons/add-circle.svg?dataurl"
|
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Field from "@lib/components/Field.svelte"
|
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
|
||||||
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
|
||||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
|
||||||
import {pushToast} from "@app/util/toast"
|
|
||||||
import {PROTECTED} from "@app/core/state"
|
|
||||||
import {canEnforceNip70} from "@app/core/commands"
|
|
||||||
import type {PollType} from "@app/util/polls"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
h?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, h}: Props = $props()
|
|
||||||
|
|
||||||
const shouldProtect = canEnforceNip70(url)
|
|
||||||
|
|
||||||
type DraftOption = {
|
|
||||||
id: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const addOption = () => {
|
|
||||||
options = [...options, {id: randomId(), value: ""}]
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeOption = (id: string) => {
|
|
||||||
options = options.filter(option => option.id !== id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateOption = (id: string, value: string) => {
|
|
||||||
options = options.map(option => (option.id === id ? {...option, value} : option))
|
|
||||||
}
|
|
||||||
|
|
||||||
const reorderOptions = (targetId: string) => {
|
|
||||||
if (!draggedOptionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
|
|
||||||
const targetIndex = options.findIndex(option => option.id === targetId)
|
|
||||||
|
|
||||||
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragStart = (e: DragEvent, id: string) => {
|
|
||||||
draggedOptionId = id
|
|
||||||
|
|
||||||
if (e.dataTransfer) {
|
|
||||||
e.dataTransfer.effectAllowed = "move"
|
|
||||||
e.dataTransfer.setData("text/plain", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragOver = (e: DragEvent, targetId: string) => {
|
|
||||||
e.preventDefault()
|
|
||||||
reorderOptions(targetId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDrop = (e: DragEvent, targetId: string) => {
|
|
||||||
e.preventDefault()
|
|
||||||
reorderOptions(targetId)
|
|
||||||
draggedOptionId = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
draggedOptionId = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!title.trim()) {
|
|
||||||
return pushToast({theme: "error", message: "Please provide a title for your poll."})
|
|
||||||
}
|
|
||||||
|
|
||||||
const nonEmptyOptions = removeUndefined(options.map(option => option.value.trim() || undefined))
|
|
||||||
|
|
||||||
if (nonEmptyOptions.length < 2) {
|
|
||||||
return pushToast({theme: "error", message: "Please provide at least two options."})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsAt && endsAt <= now()) {
|
|
||||||
return pushToast({theme: "error", message: "End time must be in the future."})
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags: string[][] = [
|
|
||||||
...nonEmptyOptions.map(option => ["option", randomId(), option]),
|
|
||||||
["polltype", pollType],
|
|
||||||
["relay", url],
|
|
||||||
]
|
|
||||||
|
|
||||||
if (endsAt) {
|
|
||||||
tags.push(["endsAt", String(endsAt)])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (h) {
|
|
||||||
tags.push(["h", h])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await shouldProtect) {
|
|
||||||
tags.push(PROTECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
publishThunk({
|
|
||||||
relays: [url],
|
|
||||||
event: makeEvent(Poll, {content: title.trim(), tags}),
|
|
||||||
})
|
|
||||||
|
|
||||||
history.back()
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = $state("")
|
|
||||||
let pollType = $state<PollType>("singlechoice")
|
|
||||||
let endsAt = $state<number | undefined>()
|
|
||||||
let options = $state<DraftOption[]>([
|
|
||||||
{id: randomId(), value: "Yes"},
|
|
||||||
{id: randomId(), value: "No"},
|
|
||||||
])
|
|
||||||
let draggedOptionId = $state<string | undefined>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(submit)}>
|
|
||||||
<ModalBody>
|
|
||||||
<ModalHeader>
|
|
||||||
<ModalTitle>Create a Poll</ModalTitle>
|
|
||||||
<ModalSubtitle>Ask a question and collect votes right in the feed.</ModalSubtitle>
|
|
||||||
</ModalHeader>
|
|
||||||
<div class="col-8 relative">
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Question*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
|
||||||
<input
|
|
||||||
autofocus={!isMobile}
|
|
||||||
bind:value={title}
|
|
||||||
class="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="What would you like to ask?" />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
{#snippet label()}
|
|
||||||
<p>Options*</p>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<div class="flex flex-col gap-2" role="list">
|
|
||||||
{#each options as option, index (option.id)}
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
draggable="true"
|
|
||||||
role="listitem"
|
|
||||||
ondragstart={e => onDragStart(e, option.id)}
|
|
||||||
ondragover={e => onDragOver(e, option.id)}
|
|
||||||
ondrop={e => onDrop(e, option.id)}
|
|
||||||
ondragend={onDragEnd}>
|
|
||||||
<div class="cursor-move opacity-70" aria-label="Drag handle">
|
|
||||||
<Icon icon={HamburgerMenu} size={4} />
|
|
||||||
</div>
|
|
||||||
<label class="input input-bordered flex w-full items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={option.value}
|
|
||||||
class="grow"
|
|
||||||
type="text"
|
|
||||||
placeholder={`Option ${index + 1}`}
|
|
||||||
oninput={e => updateOption(option.id, e.currentTarget.value)} />
|
|
||||||
</label>
|
|
||||||
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
|
|
||||||
<Icon icon={MinusCircle} size={4} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<Button class="btn btn-outline btn-sm self-end" onclick={addOption}>
|
|
||||||
<Icon icon={PlusCircle} size={4} />
|
|
||||||
Add option
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
Poll type
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<select class="select select-bordered w-full max-w-xs" bind:value={pollType}>
|
|
||||||
<option value="singlechoice">Single choice</option>
|
|
||||||
<option value="multiplechoice">Multiple choice</option>
|
|
||||||
</select>
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
<FieldInline>
|
|
||||||
{#snippet label()}
|
|
||||||
Ends at
|
|
||||||
{/snippet}
|
|
||||||
{#snippet input()}
|
|
||||||
<DateTimeInput bind:value={endsAt} />
|
|
||||||
{/snippet}
|
|
||||||
</FieldInline>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button class="btn btn-link" onclick={back}>
|
|
||||||
<Icon icon={AltArrowLeft} />
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" class="btn btn-primary">Create Poll</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {getTagValue} from "@welshman/util"
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
|
||||||
import CommentActions from "@app/components/CommentActions.svelte"
|
|
||||||
import RoomLink from "@app/components/RoomLink.svelte"
|
|
||||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
|
||||||
import {makePollPath} from "@app/util/routes"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
|
||||||
|
|
||||||
const h = getTagValue("h", event.tags)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md"
|
|
||||||
href={makePollPath(url, event.id)}>
|
|
||||||
<NoteContent {event} {url} />
|
|
||||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
|
||||||
<span class="whitespace-nowrap py-1 text-sm opacity-75">
|
|
||||||
Posted by <ProfileLink pubkey={event.pubkey} {url} />
|
|
||||||
{#if h}
|
|
||||||
in <RoomLink {url} {h} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<CommentActions segment="polls" showActivity {url} {event} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {tweened} from "svelte/motion"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {noop} from "@welshman/lib"
|
|
||||||
import {stopPropagation} from "@lib/html"
|
|
||||||
import {getPollType, isPollClosed} from "@app/util/polls"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
event: TrustedEvent
|
|
||||||
option: {id: string; label: string}
|
|
||||||
results: {voters: number; options: {id: string; votes: number}[]}
|
|
||||||
selectedIds: string[]
|
|
||||||
setSingleChoice: (id: string) => void
|
|
||||||
toggleMultipleChoice: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const {event, option, results, selectedIds, setSingleChoice, toggleMultipleChoice}: Props =
|
|
||||||
$props()
|
|
||||||
|
|
||||||
const pollType = getPollType(event)
|
|
||||||
const closed = isPollClosed(event)
|
|
||||||
|
|
||||||
const selected = $derived(
|
|
||||||
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
|
|
||||||
)
|
|
||||||
const onselect = () =>
|
|
||||||
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
|
|
||||||
|
|
||||||
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
|
|
||||||
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
|
|
||||||
|
|
||||||
const tweenedVotes = tweened(votes, {duration: 300})
|
|
||||||
const tweenedMax = tweened(maxVotes, {duration: 300})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
tweenedVotes.set(votes)
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
tweenedMax.set(maxVotes)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<label class="flex min-w-0 flex-grow items-center gap-2">
|
|
||||||
{#if !closed}
|
|
||||||
{#if pollType === "singlechoice"}
|
|
||||||
<input
|
|
||||||
name={event.id}
|
|
||||||
type="radio"
|
|
||||||
class="radio radio-primary radio-sm"
|
|
||||||
checked={selected}
|
|
||||||
onclick={stopPropagation(noop)}
|
|
||||||
onchange={onselect} />
|
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-primary checkbox-sm"
|
|
||||||
checked={selected}
|
|
||||||
onclick={stopPropagation(noop)}
|
|
||||||
onchange={onselect} />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<span class="truncate">{option.label}</span>
|
|
||||||
</label>
|
|
||||||
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
|
|
||||||
</div>
|
|
||||||
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
|
|
||||||
</div>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {onDestroy} from "svelte"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {pubkey, publishThunk, abortThunk} from "@welshman/app"
|
|
||||||
import {PollResponse} from "nostr-tools/kinds"
|
|
||||||
import {formatTimestampRelative} from "@welshman/lib"
|
|
||||||
import {deriveEvents} from "@app/core/state"
|
|
||||||
import {pushToast} from "@app/util/toast"
|
|
||||||
import {makePollResponse} from "@app/core/commands"
|
|
||||||
import PollOption from "@app/components/PollOption.svelte"
|
|
||||||
import {
|
|
||||||
getPollEndsAt,
|
|
||||||
getPollOptions,
|
|
||||||
getPollResponseSelections,
|
|
||||||
getPollResults,
|
|
||||||
getPollType,
|
|
||||||
isPollClosed,
|
|
||||||
} from "@app/util/polls"
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string
|
|
||||||
event: TrustedEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
const {url, event}: Props = $props()
|
|
||||||
|
|
||||||
const responses = deriveEvents([{kinds: [PollResponse], "#e": [event.id]}])
|
|
||||||
|
|
||||||
const pollType = getPollType(event)
|
|
||||||
const options = getPollOptions(event)
|
|
||||||
const closed = isPollClosed(event)
|
|
||||||
const endsAt = getPollEndsAt(event)
|
|
||||||
const publishDelay = pollType === "multiplechoice" ? 10_000 : undefined
|
|
||||||
|
|
||||||
const getOwnResponse = (responses: TrustedEvent[]) => {
|
|
||||||
let latest: TrustedEvent | undefined
|
|
||||||
|
|
||||||
for (const response of responses) {
|
|
||||||
if (response.pubkey !== $pubkey) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!latest || response.created_at > latest.created_at) {
|
|
||||||
latest = response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return latest
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishSelection = (selection: string[]) => {
|
|
||||||
if (activeThunk) {
|
|
||||||
abortThunk(activeThunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.length === 0) {
|
|
||||||
activeThunk = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activeThunk = publishThunk({
|
|
||||||
relays: [url],
|
|
||||||
event: makePollResponse({event, selectedIds: selection}),
|
|
||||||
delay: publishDelay,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishCurrentSelection = () => {
|
|
||||||
const selection = pollType === "singlechoice" ? selectedIds.slice(0, 1) : selectedIds
|
|
||||||
|
|
||||||
if (selection.length === 0) {
|
|
||||||
return pushToast({theme: "error", message: "Please select at least one option."})
|
|
||||||
}
|
|
||||||
|
|
||||||
publishSelection(selection)
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = $derived(getPollResults(event, $responses))
|
|
||||||
const ownResponse = $derived(getOwnResponse($responses))
|
|
||||||
|
|
||||||
const setSingleChoice = (id: string) => {
|
|
||||||
selectedIds = [id]
|
|
||||||
publishCurrentSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMultipleChoice = (id: string) => {
|
|
||||||
selectedIds = selectedIds.includes(id)
|
|
||||||
? selectedIds.filter(selectedId => selectedId !== id)
|
|
||||||
: [...selectedIds, id]
|
|
||||||
|
|
||||||
publishCurrentSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedIds = $state<string[]>([])
|
|
||||||
let activeThunk: ReturnType<typeof publishThunk> | undefined
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (ownResponse) {
|
|
||||||
selectedIds = getPollResponseSelections(ownResponse, pollType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (activeThunk) {
|
|
||||||
abortThunk(activeThunk)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each options as option (option.id)}
|
|
||||||
<PollOption {event} {option} {results} {selectedIds} {setSingleChoice} {toggleMultipleChoice} />
|
|
||||||
{/each}
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div class="text-sm opacity-75">
|
|
||||||
{pollType === "multiplechoice" ? "Multiple choice" : "Single choice"}
|
|
||||||
{#if endsAt}
|
|
||||||
{#if closed}
|
|
||||||
• Ended {formatTimestampRelative(endsAt)}
|
|
||||||
{:else}
|
|
||||||
• Ends {formatTimestampRelative(endsAt)}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm opacity-75">{results.voters} vote{results.voters === 1 ? "" : "s"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -32,14 +32,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 flex-shrink-0 bg-base-200 pt-2 md:block">
|
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
||||||
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
<div class="flex h-full flex-col" class:justify-between={PLATFORM_RELAYS.length === 0}>
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
{#if PLATFORM_RELAYS.length > 0}
|
{#if PLATFORM_RELAYS.length > 0}
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col">
|
<div>
|
||||||
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
<PrimaryNavItem
|
||||||
|
title="Settings"
|
||||||
|
href="/settings/profile"
|
||||||
|
prefix="/settings"
|
||||||
|
class="tooltip-right">
|
||||||
{#if $userProfile?.picture}
|
{#if $userProfile?.picture}
|
||||||
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -49,10 +53,11 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
title="Messages"
|
title="Messages"
|
||||||
onclick={chatHandler}
|
onclick={chatHandler}
|
||||||
|
class="tooltip-right"
|
||||||
notification={$notifications.has("/chat")}>
|
notification={$notifications.has("/chat")}>
|
||||||
<ImageIcon alt="Messages" src={Letter} size={8} />
|
<ImageIcon alt="Messages" src={Letter} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<PrimaryNavItem title="Search" href="/people">
|
<PrimaryNavItem title="Search" href="/people" class="tooltip-right">
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,10 +68,10 @@
|
|||||||
|
|
||||||
<!-- a little extra something for ios -->
|
<!-- a little extra something for ios -->
|
||||||
<div
|
<div
|
||||||
class="hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
class="bottom-nav hide-on-keyboard border-top bottom-sai fixed left-0 right-0 z-nav h-14 border border-base-200 bg-base-100 md:hidden">
|
||||||
<div class="content-padding-x content-sizing flex justify-between px-2">
|
<div class="content-padding-x content-sizing flex justify-between px-2">
|
||||||
<div class="flex gap-2 sm:gap-6">
|
<div class="flex gap-2 sm:gap-6">
|
||||||
<PrimaryNavItem title="Search" href="/people">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {deriveRelayDisplay} from "@welshman/app"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
|
||||||
import RelayIcon from "@app/components/RelayIcon.svelte"
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
import {makeSpacePath, goToSpace} from "@app/util/routes"
|
||||||
@@ -12,13 +12,11 @@
|
|||||||
const {url}: Props = $props()
|
const {url}: Props = $props()
|
||||||
|
|
||||||
const onClick = () => goToSpace(url)
|
const onClick = () => goToSpace(url)
|
||||||
|
|
||||||
const display = $derived(deriveRelayDisplay(url))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
title={$display}
|
title={displayRelayUrl(url)}
|
||||||
class="tooltip-right"
|
class="tooltip-right"
|
||||||
notification={$notifications.has(makeSpacePath(url))}>
|
notification={$notifications.has(makeSpacePath(url))}>
|
||||||
<RelayIcon {url} size={10} class="rounded-full" />
|
<RelayIcon {url} size={10} class="rounded-full" />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
const itemHeight = 56
|
const itemHeight = 56
|
||||||
const navPadding = 8 * itemHeight
|
const navPadding = 8 * itemHeight
|
||||||
const itemLimit = $derived(Math.max(0, (windowHeight - navPadding) / itemHeight))
|
const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
|
||||||
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
|
||||||
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(p => $notifications.has(p)))
|
||||||
</script>
|
</script>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<PrimaryNavItemSpace {url} />
|
<PrimaryNavItemSpace {url} />
|
||||||
{:else}
|
{:else}
|
||||||
<PrimaryNavItem title="Home" href="/home">
|
<PrimaryNavItem title="Home" href="/home" class="tooltip-right">
|
||||||
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
<ImageIcon alt="Home" src={PLATFORM_LOGO} class="rounded-full" size={10} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<PrimaryNavItem
|
<PrimaryNavItem
|
||||||
href="/spaces"
|
href="/spaces"
|
||||||
title="All Spaces"
|
title="All Spaces"
|
||||||
|
class="tooltip-right"
|
||||||
prefix="no-highlight"
|
prefix="no-highlight"
|
||||||
notification={otherSpaceNotifications}>
|
notification={otherSpaceNotifications}>
|
||||||
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
<ImageIcon alt="All Spaces" src={Widget} size={8} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Reaction from "@app/components/Reaction.svelte"
|
import Reaction from "@app/components/Reaction.svelte"
|
||||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||||
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
import {REACTION_KINDS} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -78,8 +78,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
|
||||||
|
|
||||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||||
|
|
||||||
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
const reportReasons = $derived(uniq(map(e => getTag("e", e.tags)?.[2], $reports.values())))
|
||||||
@@ -120,7 +118,7 @@
|
|||||||
|
|
||||||
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
|
||||||
<div class="flex min-w-0 flex-wrap gap-2">
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
{#if url && $reports.length > 0 && $userIsAdmin}
|
{#if url && $reports.length > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import Tooltip from "@lib/components/Tooltip.svelte"
|
|
||||||
import Modal from "@lib/components/Modal.svelte"
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
@@ -207,39 +206,39 @@
|
|||||||
<strong class="text-lg">Room Permissions</strong>
|
<strong class="text-lg">Room Permissions</strong>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
{#if $room?.isRestricted}
|
{#if $room?.isRestricted}
|
||||||
<Tooltip content="Only members can send messages.">
|
<Button
|
||||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
<Icon size={4} icon={Microphone} /> Restricted
|
data-tip="Only members can send messages.">
|
||||||
</Button>
|
<Icon size={4} icon={Microphone} /> Restricted
|
||||||
</Tooltip>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isPrivate}
|
{#if $room?.isPrivate}
|
||||||
<Tooltip content="Only members can view messages.">
|
<Button
|
||||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
<Icon size={4} icon={Lock} /> Private
|
data-tip="Only members can view messages.">
|
||||||
</Button>
|
<Icon size={4} icon={Lock} /> Private
|
||||||
</Tooltip>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isHidden}
|
{#if $room?.isHidden}
|
||||||
<Tooltip content="This room is not visible to non-members.">
|
<Button
|
||||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
data-tip="This room is not visible to non-members.">
|
||||||
</Button>
|
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||||
</Tooltip>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isClosed}
|
{#if $room?.isClosed}
|
||||||
<Tooltip content="Requests to join this room will be ignored.">
|
<Button
|
||||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
<Icon size={4} icon={MinusCircle} /> Closed
|
data-tip="Requests to join this room will be ignored.">
|
||||||
</Button>
|
<Icon size={4} icon={MinusCircle} /> Closed
|
||||||
</Tooltip>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
||||||
<Tooltip content="This room has no additional access controls.">
|
<Button
|
||||||
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
||||||
<Icon size={4} icon={Eye} /> Public
|
data-tip="This room has no additional access controls.">
|
||||||
</Button>
|
<Icon size={4} icon={Eye} /> Public
|
||||||
</Tooltip>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getPubkeyTagValues} from "@welshman/util"
|
||||||
|
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each getPubkeyTagValues(event.tags) as pubkey}
|
||||||
|
<div class="py-1 text-center text-xs opacity-75">
|
||||||
|
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
|
||||||
import {Poll} from "nostr-tools/kinds"
|
import {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
||||||
import {deriveRelay, deriveRelayDisplay, createSearch, pubkey} from "@welshman/app"
|
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
|
||||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||||
import Revote from "@assets/icons/revote.svg?dataurl"
|
|
||||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
@@ -66,13 +64,11 @@
|
|||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
const relay = deriveRelay(url)
|
const relay = deriveRelay(url)
|
||||||
const display = deriveRelayDisplay(url)
|
|
||||||
const chatPath = makeSpacePath(url, "chat")
|
const chatPath = makeSpacePath(url, "chat")
|
||||||
const goalsPath = makeSpacePath(url, "goals")
|
const goalsPath = makeSpacePath(url, "goals")
|
||||||
const threadsPath = makeSpacePath(url, "threads")
|
const threadsPath = makeSpacePath(url, "threads")
|
||||||
const classifiedsPath = makeSpacePath(url, "classifieds")
|
const classifiedsPath = makeSpacePath(url, "classifieds")
|
||||||
const calendarPath = makeSpacePath(url, "calendar")
|
const calendarPath = makeSpacePath(url, "calendar")
|
||||||
const pollsPath = makeSpacePath(url, "polls")
|
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
const otherRooms = deriveOtherRooms(url)
|
const otherRooms = deriveOtherRooms(url)
|
||||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||||
@@ -145,9 +141,7 @@
|
|||||||
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||||
onclick={openMenu}>
|
onclick={openMenu}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<strong
|
<strong class="flex items-center gap-1 relative">
|
||||||
class="flex items-center gap-1 relative tooltip tooltip-right"
|
|
||||||
data-tip={$display}>
|
|
||||||
<RelayName {url} class="ellipsize" />
|
<RelayName {url} class="ellipsize" />
|
||||||
<div
|
<div
|
||||||
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
|
||||||
@@ -263,11 +257,6 @@
|
|||||||
<Icon icon={CalendarMinimalistic} /> Calendar
|
<Icon icon={CalendarMinimalistic} /> Calendar
|
||||||
</SecondaryNavItem>
|
</SecondaryNavItem>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $spaceKinds.has(Poll)}
|
|
||||||
<SecondaryNavItem href={pollsPath}>
|
|
||||||
<Icon icon={Revote} /> Polls
|
|
||||||
</SecondaryNavItem>
|
|
||||||
{/if}
|
|
||||||
{#if hasNip29($relay)}
|
{#if hasNip29($relay)}
|
||||||
{#if $userRooms.length > 0}
|
{#if $userRooms.length > 0}
|
||||||
<div class="h-2 flex-shrink-0"></div>
|
<div class="h-2 flex-shrink-0"></div>
|
||||||
|
|||||||
@@ -24,13 +24,12 @@
|
|||||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||||
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false)
|
||||||
const roomName = $derived($room?.name || h)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if roomType === RoomType.Voice}
|
{#if roomType === RoomType.Voice}
|
||||||
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
<VoiceRoomItem {url} {h} {replaceState} {notification} />
|
||||||
{:else}
|
{:else}
|
||||||
<SecondaryNavItem href={path} title={roomName} {replaceState} {notification}>
|
<SecondaryNavItem href={path} {replaceState} {notification}>
|
||||||
<RoomNameWithImage {url} {h} />
|
<RoomNameWithImage {url} {h} />
|
||||||
{#if showDifferenceIcon}
|
{#if showDifferenceIcon}
|
||||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||||
|
|||||||
@@ -130,74 +130,76 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
<div>
|
||||||
<Icon size={4} icon={Magnifier} />
|
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||||
</button>
|
<Icon size={4} icon={Magnifier} />
|
||||||
{#if show}
|
</button>
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
{#if show}
|
||||||
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||||
<div
|
<div class="fixed cw top-0 right-0 z-feature p-2">
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
<div
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||||
<div class="flex justify-between">
|
transition:fly={{y: -40, duration: 150}}>
|
||||||
<strong>Search</strong>
|
<div class="flex justify-between">
|
||||||
<Button onclick={clear}>
|
<strong>Search</strong>
|
||||||
<Icon icon={CloseCircle} />
|
<Button onclick={clear}>
|
||||||
</Button>
|
<Icon icon={CloseCircle} />
|
||||||
</div>
|
</Button>
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
</div>
|
||||||
<Icon size={4} icon={Magnifier} />
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
<input
|
<Icon size={4} icon={Magnifier} />
|
||||||
bind:this={input}
|
<input
|
||||||
bind:value={term}
|
bind:this={input}
|
||||||
class="min-w-0 grow"
|
bind:value={term}
|
||||||
type="text"
|
class="min-w-0 grow"
|
||||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
type="text"
|
||||||
oninput={onInput} />
|
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||||
</label>
|
oninput={onInput} />
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
</label>
|
||||||
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
{#if !term}
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||||
<p class="text-sm opacity-70">
|
{#if !term}
|
||||||
{h ? "Search for content in this room." : "Search for content in this space."}
|
<p class="text-sm opacity-70">
|
||||||
</p>
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
{:else if loading}
|
</p>
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
{:else if loading}
|
||||||
{:else if eventsByAge.size === 0}
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
{:else if eventsByAge.size === 0}
|
||||||
{:else}
|
<p class="text-sm opacity-70">No results found.</p>
|
||||||
<div class="col-2">
|
{:else}
|
||||||
{#each eventsByAge as [key, events] (key)}
|
<div class="col-2">
|
||||||
<div class="col-2">
|
{#each eventsByAge as [key, events] (key)}
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
|
||||||
{#if key === "day"}
|
|
||||||
Last 24 Hours
|
|
||||||
{:else if key === "week"}
|
|
||||||
Last 7 Days
|
|
||||||
{:else}
|
|
||||||
Older
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
{#each events as event (event.id)}
|
<p class="text-xs uppercase tracking-wide opacity-60">
|
||||||
<button
|
{#if key === "day"}
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
Last 24 Hours
|
||||||
onclick={() => onRoomSearchResultClick(event)}>
|
{:else if key === "week"}
|
||||||
<p class="line-clamp-2 text-sm">
|
Last 7 Days
|
||||||
{event.content.trim() || "(No text content)"}
|
{:else}
|
||||||
</p>
|
Older
|
||||||
<div class="row-2 text-xs opacity-70">
|
{/if}
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
</p>
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
<div class="col-2">
|
||||||
</div>
|
{#each events as event (event.id)}
|
||||||
</button>
|
<button
|
||||||
{/each}
|
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||||
|
onclick={() => onRoomSearchResultClick(event)}>
|
||||||
|
<p class="line-clamp-2 text-sm">
|
||||||
|
{event.content.trim() || "(No text content)"}
|
||||||
|
</p>
|
||||||
|
<div class="row-2 text-xs opacity-70">
|
||||||
|
<span>{getAgeLabel(event.created_at)}</span>
|
||||||
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -1,98 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {parse, renderAsHtml} from "@welshman/content"
|
import {parse, renderAsHtml} from "@welshman/content"
|
||||||
import Close from "@assets/icons/close.svg?dataurl"
|
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import {toast, popToast} from "@app/util/toast"
|
import {toast, popToast} from "@app/util/toast"
|
||||||
|
|
||||||
let touchStartY = 0
|
|
||||||
let touchStartTime = 0
|
|
||||||
let dragY = $state(0)
|
|
||||||
let isSettling = $state(false)
|
|
||||||
let containerEl = $state<HTMLDivElement | undefined>(undefined)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($toast) {
|
|
||||||
dragY = 0
|
|
||||||
isSettling = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!containerEl) return
|
|
||||||
containerEl.addEventListener("touchmove", onTouchMove, {passive: false})
|
|
||||||
return () => containerEl!.removeEventListener("touchmove", onTouchMove)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onActionClick = () => {
|
const onActionClick = () => {
|
||||||
$toast!.action!.onclick()
|
$toast!.action!.onclick()
|
||||||
popToast($toast!.id)
|
popToast($toast!.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClose = () => popToast($toast!.id)
|
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
|
||||||
touchStartY = e.touches[0].clientY
|
|
||||||
touchStartTime = Date.now()
|
|
||||||
dragY = 0
|
|
||||||
isSettling = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchMove = (e: TouchEvent) => {
|
|
||||||
const delta = e.touches[0].clientY - touchStartY
|
|
||||||
if (delta < 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
isSettling = false
|
|
||||||
dragY = delta
|
|
||||||
} else {
|
|
||||||
dragY = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchEnd = (e: TouchEvent) => {
|
|
||||||
const delta = e.changedTouches[0].clientY - touchStartY
|
|
||||||
const duration = Date.now() - touchStartTime
|
|
||||||
const isQuickFlick = duration < 400 && delta < 0
|
|
||||||
const isSlowDismiss = delta < -40
|
|
||||||
|
|
||||||
if (isQuickFlick || isSlowDismiss) {
|
|
||||||
dragY = 0
|
|
||||||
popToast($toast!.id)
|
|
||||||
} else {
|
|
||||||
isSettling = true
|
|
||||||
dragY = 0
|
|
||||||
setTimeout(() => {
|
|
||||||
isSettling = false
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $toast}
|
{#if $toast}
|
||||||
{@const theme = $toast.theme || "info"}
|
{@const theme = $toast.theme || "info"}
|
||||||
<div
|
<div transition:fly class="bottom-sai right-sai toast z-toast">
|
||||||
bind:this={containerEl}
|
|
||||||
transition:fly={{y: -20}}
|
|
||||||
class="fixed z-toast top-[calc(var(--sait)+0.5rem)] left-[calc(var(--sail)+0.5rem)] right-[calc(var(--sair)+0.5rem)] flex flex-col gap-2 md:right-4 md:bottom-4 md:top-auto md:left-auto md:w-80"
|
|
||||||
style={dragY !== 0 || isSettling
|
|
||||||
? `transform: translateY(${dragY}px)${isSettling ? "; transition: transform 200ms ease-out" : ""}`
|
|
||||||
: ""}
|
|
||||||
ontouchstart={onTouchStart}
|
|
||||||
ontouchend={onTouchEnd}>
|
|
||||||
{#key $toast.id}
|
{#key $toast.id}
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
class="alert relative flex justify-center whitespace-normal text-left"
|
class="alert flex justify-center whitespace-normal text-left"
|
||||||
class:bg-base-100={theme === "info"}
|
class:bg-base-100={theme === "info"}
|
||||||
class:text-base-content={theme === "info"}
|
class:text-base-content={theme === "info"}
|
||||||
class:alert-error={theme === "error"}>
|
class:alert-error={theme === "error"}>
|
||||||
<Button
|
<p class:welshman-content-error={theme === "error"}>
|
||||||
class="absolute -top-2 -right-2 btn btn-circle btn-neutral btn-xs hidden md:inline-flex flex justify-center items-center"
|
|
||||||
onclick={onClose}>
|
|
||||||
<Icon icon={Close} size={4} />
|
|
||||||
</Button>
|
|
||||||
<p class="md:pr-6" class:welshman-content-error={theme === "error"}>
|
|
||||||
{#if $toast.message}
|
{#if $toast.message}
|
||||||
{@html renderAsHtml(parse({content: $toast.message}))}
|
{@html renderAsHtml(parse({content: $toast.message}))}
|
||||||
{#if $toast.action}
|
{#if $toast.action}
|
||||||
@@ -105,6 +35,9 @@
|
|||||||
<Component toast={$toast} {...props} />
|
<Component toast={$toast} {...props} />
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
<Button class="flex items-center opacity-75" onclick={() => popToast($toast.id)}>
|
||||||
|
<Icon icon={CloseCircle} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import {Track} from "livekit-client"
|
||||||
|
import {displayProfileByPubkey, loadProfile} from "@welshman/app"
|
||||||
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import VideoCallVideo from "@app/components/VideoCallVideo.svelte"
|
||||||
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
|
import {
|
||||||
|
currentVoiceSession,
|
||||||
|
currentVoiceRoom,
|
||||||
|
videoCallLayoutRevision,
|
||||||
|
videoPrimaryTileKey,
|
||||||
|
toggleVideoPrimaryTile,
|
||||||
|
pubkeyFromLiveKitIdentity,
|
||||||
|
} from "@app/voice"
|
||||||
|
|
||||||
|
type Variant = "mobile" | "desktop-split" | "desktop-full"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variant: Variant
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
visible?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tile = {
|
||||||
|
identity: string
|
||||||
|
isLocal: boolean
|
||||||
|
trackSid: string
|
||||||
|
attachable: Track | undefined
|
||||||
|
source: Track.Source.Camera | Track.Source.ScreenShare
|
||||||
|
}
|
||||||
|
|
||||||
|
type TileLayout = "spotlight" | "default" | "strip"
|
||||||
|
|
||||||
|
const {variant, url, h, visible = true, class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
const roomMatches = $derived($currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h)
|
||||||
|
|
||||||
|
const showPanel = $derived(visible && roomMatches)
|
||||||
|
|
||||||
|
const tiles = $derived.by(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- re-run when remote video subscribes
|
||||||
|
$videoCallLayoutRevision
|
||||||
|
const session = $currentVoiceSession
|
||||||
|
if (!session || $currentVoiceRoom?.url !== url || $currentVoiceRoom?.h !== h) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = session.room
|
||||||
|
const out: Tile[] = []
|
||||||
|
const lp = room.localParticipant
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const localPub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
out.push({
|
||||||
|
identity: lp.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-camera",
|
||||||
|
attachable: localPub?.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const localPub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
out.push({
|
||||||
|
identity: lp.identity,
|
||||||
|
isLocal: true,
|
||||||
|
trackSid: localPub?.trackSid ?? "local-screen",
|
||||||
|
attachable: localPub?.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
const camPub = rp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (camPub?.isSubscribed && camPub.track) {
|
||||||
|
out.push({
|
||||||
|
identity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: camPub.trackSid,
|
||||||
|
attachable: camPub.track,
|
||||||
|
source: Track.Source.Camera,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const screenPub = rp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (screenPub?.isSubscribed && screenPub.track) {
|
||||||
|
out.push({
|
||||||
|
identity: rp.identity,
|
||||||
|
isLocal: false,
|
||||||
|
trackSid: screenPub.trackSid,
|
||||||
|
attachable: screenPub.track,
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Identity + source only — LiveKit can change trackSid after publish, which broke spotlight + stale-key effect. */
|
||||||
|
const tileKey = (t: Tile) => `${t.identity}\x1f${t.source}`
|
||||||
|
|
||||||
|
const primaryTile = $derived.by(() => {
|
||||||
|
const k = $videoPrimaryTileKey
|
||||||
|
if (k === undefined) return undefined
|
||||||
|
return tiles.find(t => tileKey(t) === k)
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondaryTiles = $derived.by(() => {
|
||||||
|
const p = primaryTile
|
||||||
|
if (p === undefined) return tiles
|
||||||
|
const pk = tileKey(p)
|
||||||
|
return tiles.filter(t => tileKey(t) !== pk)
|
||||||
|
})
|
||||||
|
|
||||||
|
const useSpotlightLayout = $derived(primaryTile !== undefined)
|
||||||
|
const useMultiGrid = $derived(!useSpotlightLayout && tiles.length > 2)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const k = $videoPrimaryTileKey
|
||||||
|
if (k === undefined) return
|
||||||
|
if (!tiles.some(t => tileKey(t) === k)) {
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const t of tiles) {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(t.identity)
|
||||||
|
if (pk) loadProfile(pk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelFor = (identity: string, source: Tile["source"]) => {
|
||||||
|
const pk = pubkeyFromLiveKitIdentity(identity)
|
||||||
|
const name = pk ? displayProfileByPubkey(pk) : "Unknown"
|
||||||
|
return source === Track.Source.ScreenShare ? `${name} · screen` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTileGrid = $derived(tiles.length > 0)
|
||||||
|
|
||||||
|
const spotlightHandlerFor = (key: string) => () => {
|
||||||
|
toggleVideoPrimaryTile(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelChrome = $derived(
|
||||||
|
cx(
|
||||||
|
variant === "mobile" &&
|
||||||
|
"cb top-[calc(var(--sait)+6rem)] cw z-compose bg-[var(--video-call-panel-bg)] fixed inset-x-0 flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden px-2 pb-2 pt-1 md:hidden",
|
||||||
|
variant === "desktop-split" &&
|
||||||
|
"cb ct cw-split-video z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
|
||||||
|
variant === "desktop-full" &&
|
||||||
|
"cb ct cw z-compose bg-[var(--video-call-panel-bg)] fixed hidden min-h-0 flex-col gap-2 overflow-hidden p-2 md:flex",
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet videoTile(tile: Tile, layout: TileLayout)}
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"relative isolate overflow-hidden rounded-box shadow-sm",
|
||||||
|
layout === "spotlight" && "min-h-0 flex-1",
|
||||||
|
layout === "default" && "aspect-video w-full min-h-0",
|
||||||
|
layout === "strip" && "aspect-video w-44 shrink-0",
|
||||||
|
tile.source === Track.Source.ScreenShare ? "bg-black" : "bg-base-100",
|
||||||
|
)}>
|
||||||
|
{#if tile.attachable}
|
||||||
|
<VideoCallVideo
|
||||||
|
track={tile.attachable}
|
||||||
|
muted={tile.isLocal}
|
||||||
|
fit={tile.source === Track.Source.ScreenShare ? "contain" : "cover"}
|
||||||
|
class="pointer-events-none absolute inset-0" />
|
||||||
|
{:else}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<ProfileCircle pubkey={pubkeyFromLiveKitIdentity(tile.identity)} {url} size={14} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
|
||||||
|
{labelFor(tile.identity, tile.source)}{tile.isLocal ? " (you)" : ""}
|
||||||
|
</span>
|
||||||
|
{#if tiles.length > 1}
|
||||||
|
{@const pinned = $videoPrimaryTileKey === tileKey(tile)}
|
||||||
|
<Button
|
||||||
|
data-tip={pinned ? "Exit spotlight" : "Spotlight"}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
class={cx(
|
||||||
|
"absolute right-1 top-1 z-20 btn btn-xs btn-square btn-ghost",
|
||||||
|
pinned ? "btn-active bg-primary/25 text-primary" : "bg-base-100/70",
|
||||||
|
)}
|
||||||
|
onclick={spotlightHandlerFor(tileKey(tile))}>
|
||||||
|
<Icon icon={Pin} size={3} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet videoPanelBody()}
|
||||||
|
{#if showTileGrid}
|
||||||
|
{#if useSpotlightLayout && primaryTile}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
{@render videoTile(primaryTile, "spotlight")}
|
||||||
|
{#if secondaryTiles.length > 0}
|
||||||
|
<div
|
||||||
|
class="flex max-h-40 shrink-0 flex-row gap-2 overflow-x-auto overflow-y-hidden py-0.5">
|
||||||
|
{#each secondaryTiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "strip")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if useMultiGrid}
|
||||||
|
<div
|
||||||
|
class="grid min-h-0 flex-1 grid-cols-1 content-start gap-2 overflow-y-auto sm:grid-cols-2">
|
||||||
|
{#each tiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "default")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
{#each tiles as tile (tileKey(tile))}
|
||||||
|
{@render videoTile(tile, "default")}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex min-h-[12rem] flex-1 flex-col items-center justify-center gap-2 rounded-box bg-base-200/50 p-4 text-center text-sm opacity-80">
|
||||||
|
<p>No camera or screen share yet.</p>
|
||||||
|
<p class="text-xs">Use the camera or screen share control to share video.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if showPanel}
|
||||||
|
<div class={panelChrome}>
|
||||||
|
{#if variant === "mobile"}
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{@render videoPanelBody()}
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<VoiceWidget />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render videoPanelBody()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {Track} from "livekit-client"
|
||||||
|
import cx from "classnames"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
track: Track
|
||||||
|
muted?: boolean
|
||||||
|
fit?: "cover" | "contain"
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {track, muted = true, fit = "cover", class: className = ""}: Props = $props()
|
||||||
|
|
||||||
|
let el = $state<HTMLVideoElement | undefined>()
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const v = el
|
||||||
|
const t = track
|
||||||
|
if (!v) return
|
||||||
|
t.attach(v)
|
||||||
|
return () => {
|
||||||
|
t.detach(v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<video
|
||||||
|
bind:this={el}
|
||||||
|
class={cx("h-full w-full", fit === "contain" ? "object-contain" : "object-cover", className)}
|
||||||
|
playsinline
|
||||||
|
{muted}></video>
|
||||||
@@ -26,8 +26,10 @@
|
|||||||
|
|
||||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let selectedInput = $state("")
|
let selectedInput = $state("")
|
||||||
let selectedOutput = $state("")
|
let selectedOutput = $state("")
|
||||||
|
let selectedVideo = $state("")
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
@@ -35,16 +37,25 @@
|
|||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||||
|
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||||
} catch {
|
} catch {
|
||||||
audioInputs = []
|
audioInputs = []
|
||||||
audioOutputs = []
|
audioOutputs = []
|
||||||
|
videoInputs = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadDevices()
|
void loadDevices()
|
||||||
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
const md = navigator.mediaDevices
|
||||||
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
if (!md?.addEventListener) return
|
||||||
|
const onDeviceChange = () => {
|
||||||
|
void loadDevices()
|
||||||
|
}
|
||||||
|
md.addEventListener("devicechange", onDeviceChange)
|
||||||
|
return () => {
|
||||||
|
md.removeEventListener("devicechange", onDeviceChange)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -55,6 +66,7 @@
|
|||||||
}
|
}
|
||||||
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
|
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onInputChange = () => {
|
const onInputChange = () => {
|
||||||
@@ -65,6 +77,10 @@
|
|||||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onVideoChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||||
|
}
|
||||||
|
|
||||||
const onDone = () => {
|
const onDone = () => {
|
||||||
popModal()
|
popModal()
|
||||||
}
|
}
|
||||||
@@ -76,8 +92,8 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Audio settings</ModalTitle>
|
<ModalTitle>Call settings</ModalTitle>
|
||||||
<ModalSubtitle>Choose microphone and speaker for this call.</ModalSubtitle>
|
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div class="flex flex-col gap-4 pt-2">
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
<FieldInline>
|
<FieldInline>
|
||||||
@@ -120,6 +136,25 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
{/if}
|
{/if}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Camera</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedVideo}
|
||||||
|
onchange={onVideoChange}
|
||||||
|
aria-label="Camera">
|
||||||
|
<option value="">Default camera</option>
|
||||||
|
{#each videoInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Camera ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {fly} from "svelte/transition"
|
import {fade, fly} from "svelte/transition"
|
||||||
|
import {browser} from "$app/environment"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import cx from "classnames"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
import Videocamera from "@assets/icons/videocamera.svg?dataurl"
|
||||||
|
import VideocameraRecord from "@assets/icons/videocamera-record.svg?dataurl"
|
||||||
|
import Monitor from "@assets/icons/monitor.svg?dataurl"
|
||||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
||||||
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Settings from "@assets/icons/settings.svg?dataurl"
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
@@ -23,14 +28,20 @@
|
|||||||
type Room,
|
type Room,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {notifications} from "@app/util/notifications"
|
||||||
import {makeRoomPath} from "@app/util/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
import {
|
import {
|
||||||
VoiceState,
|
VoiceState,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
voiceState,
|
||||||
|
voiceMobileRoomPanel,
|
||||||
|
voiceDesktopRoomPanel,
|
||||||
|
isLocalSpeaking,
|
||||||
leaveVoiceRoom,
|
leaveVoiceRoom,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
|
toggleCamera,
|
||||||
|
toggleScreenShare,
|
||||||
cancelJoinVoiceRoom,
|
cancelJoinVoiceRoom,
|
||||||
} from "@app/voice"
|
} from "@app/voice"
|
||||||
|
|
||||||
@@ -66,29 +77,121 @@
|
|||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAudioSettings = () => {
|
const goToRoom = () => {
|
||||||
|
if (!targetRoom) return
|
||||||
|
const path = makeRoomPath(targetRoom.url, targetRoom.h)
|
||||||
|
if ($page.url.pathname !== path) {
|
||||||
|
void goto(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCallSettings = () => {
|
||||||
pushModal(VoiceCallAudioSettingsDialog)
|
pushModal(VoiceCallAudioSettingsDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isMd = $state(
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches,
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return
|
||||||
|
const mq = window.matchMedia("(min-width: 768px)")
|
||||||
|
const sync = () => {
|
||||||
|
isMd = mq.matches
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
mq.addEventListener("change", sync)
|
||||||
|
return () => mq.removeEventListener("change", sync)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showVoiceLayoutToggle = $derived(
|
||||||
|
$voiceState === VoiceState.Connected &&
|
||||||
|
targetRoom !== undefined &&
|
||||||
|
getRoomType(targetRoom) === RoomType.Voice &&
|
||||||
|
typeof h === "string" &&
|
||||||
|
relay !== undefined &&
|
||||||
|
decodeRelay(relay) === targetRoom.url &&
|
||||||
|
h === targetRoom.h,
|
||||||
|
)
|
||||||
|
|
||||||
|
const layoutToggleActive = $derived(
|
||||||
|
showVoiceLayoutToggle &&
|
||||||
|
((!isMd && $voiceMobileRoomPanel === "chat") || (isMd && $voiceDesktopRoomPanel === "split")),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onLayoutToggle = () => {
|
||||||
|
if (!showVoiceLayoutToggle) return
|
||||||
|
if (isMd) {
|
||||||
|
voiceDesktopRoomPanel.update(p => (p === "split" ? "chat" : "split"))
|
||||||
|
} else {
|
||||||
|
voiceMobileRoomPanel.update(p => (p === "chat" ? "video" : "chat"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatUnread = $derived(
|
||||||
|
targetRoom !== undefined && $notifications.has(makeRoomPath(targetRoom.url, targetRoom.h)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const mediaToggleClass = "center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet mutedSlash(show: boolean)}
|
||||||
|
{#if show}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
|
||||||
|
aria-hidden="true">
|
||||||
|
<span class="h-[1.3px] w-[150%] max-w-none shrink-0 -rotate-45 rounded-full bg-current"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#if targetRoom}
|
{#if targetRoom}
|
||||||
<div
|
<div
|
||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex items-start justify-between gap-2">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
<button
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
type="button"
|
||||||
{:else if $voiceState === VoiceState.Connected}
|
class="min-w-0 flex-1 rounded-lg px-1 py-0.5 text-left outline-none hover:bg-base-200/60 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
|
||||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
onclick={goToRoom}
|
||||||
{:else}
|
aria-label="Open room {roomName}">
|
||||||
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#if $voiceState === VoiceState.Joining}
|
||||||
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
|
{:else if $voiceState === VoiceState.Connected}
|
||||||
|
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
||||||
|
{/if}
|
||||||
|
<span class="ellipsize text-xs opacity-70">
|
||||||
|
{roomName} / {spaceName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if showVoiceLayoutToggle}
|
||||||
|
<Button
|
||||||
|
data-tip="Toggle Chat"
|
||||||
|
class={cx(
|
||||||
|
mediaToggleClass,
|
||||||
|
"relative shrink-0 overflow-visible",
|
||||||
|
layoutToggleActive && "text-primary",
|
||||||
|
)}
|
||||||
|
onclick={onLayoutToggle}>
|
||||||
|
<span class="relative inline-flex">
|
||||||
|
<Icon icon={ChatRound} size={4} />
|
||||||
|
{#if chatUnread}
|
||||||
|
<span
|
||||||
|
transition:fade={{duration: 150}}
|
||||||
|
class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary ring-2 ring-base-100"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="ellipsize text-xs opacity-70">
|
|
||||||
{roomName} / {spaceName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if $voiceState === VoiceState.Joining}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
<Button
|
<Button
|
||||||
@@ -100,16 +203,35 @@
|
|||||||
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
class={cx(
|
||||||
? 'btn-error'
|
mediaToggleClass,
|
||||||
: 'btn-ghost'}"
|
"overflow-visible",
|
||||||
|
!$currentVoiceSession.muted && $isLocalSpeaking && "text-primary",
|
||||||
|
$currentVoiceSession.muted &&
|
||||||
|
"text-error ring-1 ring-error/50 ring-offset-0 ring-offset-base-100",
|
||||||
|
)}
|
||||||
onclick={toggleMute}>
|
onclick={toggleMute}>
|
||||||
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
<span class="relative inline-flex items-center justify-center overflow-visible">
|
||||||
|
<Icon icon={Microphone} size={4} />
|
||||||
|
{@render mutedSlash($currentVoiceSession.muted)}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Audio settings"
|
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
||||||
|
class={cx(mediaToggleClass, $currentVoiceSession.cameraOn && "text-primary")}
|
||||||
|
onclick={toggleCamera}>
|
||||||
|
<Icon icon={$currentVoiceSession.cameraOn ? VideocameraRecord : Videocamera} size={4} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-tip={$currentVoiceSession.screenShareOn ? "Stop sharing" : "Share screen"}
|
||||||
|
class={cx(mediaToggleClass, $currentVoiceSession.screenShareOn && "text-primary")}
|
||||||
|
onclick={toggleScreenShare}>
|
||||||
|
<Icon icon={Monitor} size={4} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-tip="Call settings"
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
onclick={openAudioSettings}>
|
onclick={openCallSettings}>
|
||||||
<Icon icon={Settings} size={4} />
|
<Icon icon={Settings} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import {Nip01Signer} from "@welshman/signer"
|
import {Nip01Signer} from "@welshman/signer"
|
||||||
import type {UploadTask} from "@welshman/editor"
|
import type {UploadTask} from "@welshman/editor"
|
||||||
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
|
||||||
import {PollResponse} from "nostr-tools/kinds"
|
|
||||||
import {
|
import {
|
||||||
DELETE,
|
DELETE,
|
||||||
REPORT,
|
REPORT,
|
||||||
@@ -352,22 +351,6 @@ export const publishReaction = ({relays, ...params}: ReactionParams & {relays: s
|
|||||||
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
publishThunk({event: makeReaction({url: relays[0], ...params}), relays})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Polls
|
|
||||||
|
|
||||||
export type PollResponseParams = {
|
|
||||||
event: TrustedEvent
|
|
||||||
selectedIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makePollResponse = ({event, selectedIds}: PollResponseParams) =>
|
|
||||||
makeEvent(PollResponse, {
|
|
||||||
content: "",
|
|
||||||
tags: [["e", event.id], ...selectedIds.map(selectedId => ["response", selectedId])],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const publishPollResponse = ({relays, ...params}: PollResponseParams & {relays: string[]}) =>
|
|
||||||
publishThunk({event: makePollResponse(params), relays})
|
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
|
|
||||||
export type CommentParams = {
|
export type CommentParams = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {context as pomadeContext} from "@pomade/core"
|
|||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {derived, readable, writable} from "svelte/store"
|
import {derived, readable, writable} from "svelte/store"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import {
|
import {
|
||||||
on,
|
on,
|
||||||
gt,
|
gt,
|
||||||
@@ -326,7 +325,7 @@ if (ENABLE_ZAPS) {
|
|||||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
|
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
|
||||||
|
|
||||||
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {page} from "$app/stores"
|
|||||||
import type {Unsubscriber} from "svelte/store"
|
import type {Unsubscriber} from "svelte/store"
|
||||||
import {derived, get} from "svelte/store"
|
import {derived, get} from "svelte/store"
|
||||||
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
import {last, call, ifLet, assoc, chunk, sleep, identity, WEEK, ago} from "@welshman/lib"
|
||||||
import {PollResponse} from "nostr-tools/kinds"
|
|
||||||
import {
|
import {
|
||||||
getListTags,
|
getListTags,
|
||||||
getRelayTagValues,
|
getRelayTagValues,
|
||||||
@@ -282,7 +281,6 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
filters: [
|
filters: [
|
||||||
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
{kinds: MESSAGE_KINDS, since, "#h": [room]},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
|
||||||
{kinds: [PollResponse], since},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -302,7 +300,6 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
filters: [
|
filters: [
|
||||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...MESSAGE_KINDS]},
|
||||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||||
{kinds: [PollResponse], since},
|
|
||||||
],
|
],
|
||||||
onEvent: event => {
|
onEvent: event => {
|
||||||
if (event.kind === ROOM_META) {
|
if (event.kind === ROOM_META) {
|
||||||
@@ -314,7 +311,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
listen({
|
listen({
|
||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
|
filters: [{kinds: REACTION_KINDS}],
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import {now, removeUndefined, uniq} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {getTagValue, getTags, getTagValues} from "@welshman/util"
|
|
||||||
|
|
||||||
export type PollType = "singlechoice" | "multiplechoice"
|
|
||||||
|
|
||||||
export type PollOption = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
votes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPollType = (event: TrustedEvent): PollType =>
|
|
||||||
getTagValue("polltype", event.tags) === "multiplechoice" ? "multiplechoice" : "singlechoice"
|
|
||||||
|
|
||||||
export const getPollOptions = (event: TrustedEvent) =>
|
|
||||||
removeUndefined(
|
|
||||||
getTags("option", event.tags).map(tag => {
|
|
||||||
const [, id, label = id] = tag
|
|
||||||
|
|
||||||
if (!id) return undefined
|
|
||||||
|
|
||||||
return {id, label}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const getPollEndsAt = (event: TrustedEvent) => {
|
|
||||||
const endsAt = getTagValue("endsAt", event.tags)
|
|
||||||
|
|
||||||
if (!endsAt) return undefined
|
|
||||||
|
|
||||||
const timestamp = parseInt(endsAt)
|
|
||||||
|
|
||||||
return Number.isNaN(timestamp) ? undefined : timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isPollClosed = (event: TrustedEvent) => {
|
|
||||||
const endsAt = getPollEndsAt(event)
|
|
||||||
|
|
||||||
return typeof endsAt === "number" ? endsAt <= now() : false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPollResponseSelections = (event: TrustedEvent, pollType = getPollType(event)) => {
|
|
||||||
const selections = getTagValues("response", event.tags)
|
|
||||||
|
|
||||||
return pollType === "singlechoice" ? selections.slice(0, 1) : uniq(selections)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPollResults = (event: TrustedEvent, responses: TrustedEvent[]) => {
|
|
||||||
const options = getPollOptions(event).map(option => ({...option, votes: 0}))
|
|
||||||
const counts = new Map(options.map(option => [option.id, option]))
|
|
||||||
const latestByPubkey = new Map<string, TrustedEvent>()
|
|
||||||
|
|
||||||
for (const response of responses) {
|
|
||||||
const current = latestByPubkey.get(response.pubkey)
|
|
||||||
|
|
||||||
if (!current || response.created_at > current.created_at) {
|
|
||||||
latestByPubkey.set(response.pubkey, response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const response of latestByPubkey.values()) {
|
|
||||||
for (const optionId of getPollResponseSelections(response, getPollType(event))) {
|
|
||||||
const option = counts.get(optionId)
|
|
||||||
|
|
||||||
if (option) {
|
|
||||||
option.votes += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
options,
|
|
||||||
voters: latestByPubkey.size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import {page} from "$app/stores"
|
|||||||
import {nthEq} from "@welshman/lib"
|
import {nthEq} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {getAddress} from "@welshman/util"
|
import {getAddress} from "@welshman/util"
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import {tracker, userMessagingRelayList} from "@welshman/app"
|
import {tracker, userMessagingRelayList} from "@welshman/app"
|
||||||
import {identity} from "@welshman/lib"
|
import {identity} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
@@ -91,8 +90,6 @@ export const makeClassifiedPath = (url: string, address?: string) =>
|
|||||||
export const makeCalendarPath = (url: string, address?: string) =>
|
export const makeCalendarPath = (url: string, address?: string) =>
|
||||||
makeSpacePath(url, "calendar", address)
|
makeSpacePath(url, "calendar", address)
|
||||||
|
|
||||||
export const makePollPath = (url: string, id?: string) => makeSpacePath(url, "polls", id)
|
|
||||||
|
|
||||||
export const scrollToEvent = (id: string) => {
|
export const scrollToEvent = (id: string) => {
|
||||||
const element = document.querySelector(`[data-event="${id}"]`) as any
|
const element = document.querySelector(`[data-event="${id}"]`) as any
|
||||||
|
|
||||||
@@ -149,10 +146,6 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
|||||||
return makeCalendarPath(url, getAddress(event))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind === Poll) {
|
|
||||||
return makePollPath(url, event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind === MESSAGE) {
|
if (event.kind === MESSAGE) {
|
||||||
return makeMessagePath(url, event)
|
return makeMessagePath(url, event)
|
||||||
}
|
}
|
||||||
@@ -199,7 +192,5 @@ export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
|||||||
return makeGoalPath(url, event.id)
|
return makeGoalPath(url, event.id)
|
||||||
case EVENT_TIME:
|
case EVENT_TIME:
|
||||||
return makeCalendarPath(url, getAddress(event))
|
return makeCalendarPath(url, getAddress(event))
|
||||||
case Poll:
|
|
||||||
return makePollPath(url, event.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const staticTitles = new Map<string, string>([
|
|||||||
["/spaces/[relay]/classifieds", "Classifieds"],
|
["/spaces/[relay]/classifieds", "Classifieds"],
|
||||||
["/spaces/[relay]/calendar", "Calendar"],
|
["/spaces/[relay]/calendar", "Calendar"],
|
||||||
["/spaces/[relay]/goals", "Goals"],
|
["/spaces/[relay]/goals", "Goals"],
|
||||||
["/spaces/[relay]/polls", "Polls"],
|
|
||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
["/people", "Find People"],
|
||||||
@@ -36,7 +35,6 @@ const eventRoutes = new Set([
|
|||||||
"/spaces/[relay]/goals/[id]",
|
"/spaces/[relay]/goals/[id]",
|
||||||
"/spaces/[relay]/calendar/[address]",
|
"/spaces/[relay]/calendar/[address]",
|
||||||
"/spaces/[relay]/classifieds/[address]",
|
"/spaces/[relay]/classifieds/[address]",
|
||||||
"/spaces/[relay]/polls/[id]",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
type RouteParams = Record<string, string | undefined>
|
type RouteParams = Record<string, string | undefined>
|
||||||
|
|||||||
+162
-2
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
LocalParticipant,
|
||||||
|
LocalTrackPublication,
|
||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
supportsAudioOutputSelection,
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
type LocalParticipant,
|
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
@@ -32,6 +33,8 @@ export type VoiceSession = {
|
|||||||
h: string
|
h: string
|
||||||
room: LiveKitRoom
|
room: LiveKitRoom
|
||||||
muted: boolean
|
muted: boolean
|
||||||
|
cameraOn: boolean
|
||||||
|
screenShareOn: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Pubkey = string
|
export type Pubkey = string
|
||||||
@@ -51,6 +54,7 @@ const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
|||||||
export enum DeviceKind {
|
export enum DeviceKind {
|
||||||
AudioInput = "audioinput",
|
AudioInput = "audioinput",
|
||||||
AudioOutput = "audiooutput",
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const switchVoiceActiveDevice = async (
|
export const switchVoiceActiveDevice = async (
|
||||||
@@ -71,6 +75,9 @@ export const switchVoiceActiveDevice = async (
|
|||||||
case DeviceKind.AudioOutput:
|
case DeviceKind.AudioOutput:
|
||||||
label = "speaker"
|
label = "speaker"
|
||||||
break
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
}
|
}
|
||||||
pushToast({theme: "error", message: `Error changing ${label}`})
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
}
|
}
|
||||||
@@ -80,8 +87,31 @@ export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
|||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
|
/** Mobile room UI: full-screen chat vs video (see VoiceWidget layout toggle). */
|
||||||
|
export const voiceMobileRoomPanel = writable<"chat" | "video">("chat")
|
||||||
|
|
||||||
|
/** Desktop room UI: messages only, video only, or split (see VoiceWidget layout toggle). */
|
||||||
|
export const voiceDesktopRoomPanel = writable<"chat" | "video" | "split">("split")
|
||||||
|
|
||||||
|
const resetVoiceRoomPanels = () => {
|
||||||
|
voiceMobileRoomPanel.set("chat")
|
||||||
|
voiceDesktopRoomPanel.set("chat")
|
||||||
|
}
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
|
/** Bumps when remote video is subscribed/unsubscribed so layout/video UI can react. */
|
||||||
|
export const videoCallLayoutRevision = writable(0)
|
||||||
|
|
||||||
|
/** Spotlight tile id — must match VideoCallContent `tileKey` (identity + source, not trackSid). */
|
||||||
|
export const videoPrimaryTileKey = writable<string | undefined>(undefined)
|
||||||
|
|
||||||
|
export const toggleVideoPrimaryTile = (key: string) => {
|
||||||
|
videoPrimaryTileKey.update(k => (k === key ? undefined : key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const bumpVideoCallLayoutRevision = () => videoCallLayoutRevision.update(n => n + 1)
|
||||||
|
|
||||||
const addParticipant = (identity: string) => {
|
const addParticipant = (identity: string) => {
|
||||||
participantPubkeyMap.update(m => {
|
participantPubkeyMap.update(m => {
|
||||||
const next = new Map(m)
|
const next = new Map(m)
|
||||||
@@ -116,6 +146,16 @@ export const isParticipantSpeaking = derived(
|
|||||||
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
$participants.some(sp => participantKey(sp) === participantKey(p)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** True when the local user is in LiveKit’s active-speakers list (currently talking). */
|
||||||
|
export const isLocalSpeaking = derived(
|
||||||
|
[currentVoiceSession, speakingParticipants],
|
||||||
|
([$session, $speaking]) => {
|
||||||
|
if (!$session?.room) return false
|
||||||
|
const local = participantFromLiveKitIdentity($session.room.localParticipant.identity)
|
||||||
|
return $speaking.some(sp => participantKey(sp) === participantKey(local))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const fetchLivekitToken = async (
|
const fetchLivekitToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
@@ -197,7 +237,10 @@ const setUpMicrophone = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
|
videoCallLayoutRevision.set(0)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVoiceRoomPanels()
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
@@ -216,11 +259,16 @@ const onTrackSubscribed = (track: Track) => {
|
|||||||
element.style.display = "none"
|
element.style.display = "none"
|
||||||
document.body.appendChild(element)
|
document.body.appendChild(element)
|
||||||
element.play().catch(() => {})
|
element.play().catch(() => {})
|
||||||
|
} else if (track.kind === Track.Kind.Video) {
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTrackUnsubscribed = (track: Track) => {
|
const onTrackUnsubscribed = (track: Track) => {
|
||||||
track.detach().forEach(el => el.remove())
|
track.detach().forEach(el => el.remove())
|
||||||
|
if (track.kind === Track.Kind.Video) {
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
|
||||||
@@ -241,6 +289,18 @@ const onParticipantDisconnected = (participant: {identity: string}) => {
|
|||||||
deleteParticipant(participant.identity)
|
deleteParticipant(participant.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLocalTrackUnpublished = (
|
||||||
|
publication: LocalTrackPublication,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
) => {
|
||||||
|
if (publication.source !== Track.Source.ScreenShare) return
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session || participant.identity !== session.room.localParticipant.identity) return
|
||||||
|
if (!session.screenShareOn) return
|
||||||
|
currentVoiceSession.set({...session, screenShareOn: false})
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
}
|
||||||
|
|
||||||
let joinAbortController: AbortController | undefined
|
let joinAbortController: AbortController | undefined
|
||||||
|
|
||||||
export const cancelJoinVoiceRoom = () => {
|
export const cancelJoinVoiceRoom = () => {
|
||||||
@@ -278,6 +338,7 @@ export const joinVoiceRoom = async (
|
|||||||
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
|
liveKitRoom.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
|
||||||
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -301,7 +362,14 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
currentVoiceSession.set({
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
room: liveKitRoom,
|
||||||
|
muted,
|
||||||
|
cameraOn: false,
|
||||||
|
screenShareOn: false,
|
||||||
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -320,8 +388,27 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
|
if (session.cameraOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
} catch {
|
||||||
|
/* pass */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
} catch {
|
||||||
|
/* pass */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
voiceState.set(VoiceState.Disconnected)
|
voiceState.set(VoiceState.Disconnected)
|
||||||
|
videoCallLayoutRevision.set(0)
|
||||||
|
videoPrimaryTileKey.set(undefined)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
|
resetVoiceRoomPanels()
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
@@ -352,3 +439,76 @@ export const toggleMute = async () => {
|
|||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VISUAL_SOURCES = [Track.Source.Camera, Track.Source.ScreenShare] as const
|
||||||
|
|
||||||
|
const countLiveVisualFeeds = (session: VoiceSession): number => {
|
||||||
|
const room = session.room
|
||||||
|
let n = 0
|
||||||
|
const lp = room.localParticipant
|
||||||
|
if (session.cameraOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.Camera)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
if (session.screenShareOn) {
|
||||||
|
const pub = lp.getTrackPublication(Track.Source.ScreenShare)
|
||||||
|
if (pub?.track) n += 1
|
||||||
|
}
|
||||||
|
for (const rp of room.remoteParticipants.values()) {
|
||||||
|
for (const source of VISUAL_SOURCES) {
|
||||||
|
const pub = rp.getTrackPublication(source)
|
||||||
|
if (pub?.isSubscribed && pub.track) n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export const videoTileCount = derived(
|
||||||
|
[currentVoiceSession, voiceState, videoCallLayoutRevision],
|
||||||
|
([$session, $state, _rev]) => {
|
||||||
|
if ($state !== VoiceState.Connected || !$session) return 0
|
||||||
|
return countLiveVisualFeeds($session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const toggleCamera = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const cameraOn = !session.cameraOn
|
||||||
|
if (!cameraOn) {
|
||||||
|
session.room.localParticipant.setCameraEnabled(false)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setCameraEnabled(true)
|
||||||
|
currentVoiceSession.set({...session, cameraOn})
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: "Could not access camera"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleScreenShare = async () => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const screenShareOn = !session.screenShareOn
|
||||||
|
if (!screenShareOn) {
|
||||||
|
session.room.localParticipant.setScreenShareEnabled(false)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.room.localParticipant.setScreenShareEnabled(true)
|
||||||
|
currentVoiceSession.set({...session, screenShareOn})
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: "Could not start screen sharing"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
style?: string
|
style?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
"data-tip"?: string
|
"data-tip"?: string
|
||||||
|
"aria-pressed"?: boolean
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const className = $derived(`text-left ${restProps.class}`)
|
const className = $derived(`text-left ${restProps.class}`)
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-component="Page"
|
data-component="Page"
|
||||||
class="relative flex-grow flex flex-col min-w-0 ml-sai mb-sai mt-sai mr-sai bg-base-200 md:ml-0 md:mb-0 {props.class}">
|
class="scroll-container bottom-sai top-sai cw fixed mb-14 overflow-auto bg-base-200 md:mb-0 {props.class}">
|
||||||
{@render props.children?.()}
|
{@render props.children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
const {children, ...props}: Props = $props()
|
const {children, ...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div data-component="PageBar" class="relative z-nav p-2 -mb-4 {props.class}">
|
<div data-component="PageBar" class="cw top-sai fixed z-nav p-2 {props.class}">
|
||||||
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
|
<div class="rounded-xl bg-base-100 p-4 shadow-md h-20 md:h-12 flex flex-col justify-center">
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,20 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
element?: Element
|
element?: Element
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
|
/** Desktop voice: chat occupies the right half in split view. */
|
||||||
|
contentFrame?: "default" | "split-right"
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
let {children, element = $bindable(), ...props}: Props = $props()
|
let {children, element = $bindable(), contentFrame = "default", ...props}: Props = $props()
|
||||||
|
|
||||||
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden")
|
const className = $derived(
|
||||||
|
cx(
|
||||||
|
props.class,
|
||||||
|
"scroll-container cb ct fixed z-feature overflow-y-auto overflow-x-hidden",
|
||||||
|
contentFrame === "split-right" ? "cw-split-chat" : "cw",
|
||||||
|
),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
<div {...props} bind:this={element} data-component="PageContent" class={className}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
||||||
@@ -14,35 +13,32 @@
|
|||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
const active = $derived($page.url?.pathname?.startsWith(prefix || href || "bogus"))
|
||||||
|
|
||||||
const wrapperClass = $derived(
|
|
||||||
cx("relative h-14 w-14 p-1", {
|
|
||||||
"tooltip tooltip-right": title,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const innerClass = $derived(
|
|
||||||
cx(
|
|
||||||
"flex h-full w-full cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-base-300",
|
|
||||||
restProps.class,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={wrapperClass} data-tip={title}>
|
{#if onclick}
|
||||||
{#if onclick}
|
<Button {onclick} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
|
||||||
<Button {onclick} class={innerClass}>
|
<div
|
||||||
|
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
|
||||||
|
class:bg-base-300={active}
|
||||||
|
class:tooltip={title}
|
||||||
|
data-tip={title}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if !active && notification}
|
{#if !active && notification}
|
||||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</div>
|
||||||
{:else}
|
</Button>
|
||||||
<a {href} class={innerClass}>
|
{:else}
|
||||||
|
<a {href} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
|
||||||
|
<div
|
||||||
|
class="aspect-square flex-grow cursor-pointer rounded-full {restProps.class} flex items-center justify-center transition-colors hover:bg-base-300"
|
||||||
|
class:bg-base-300={active}
|
||||||
|
class:tooltip={title}
|
||||||
|
data-tip={title}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if !active && notification}
|
{#if !active && notification}
|
||||||
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</div>
|
||||||
{/if}
|
</a>
|
||||||
</div>
|
{/if}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"mt-sai mb-sai max-h-screen w-60 min-h-0 flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
|
"ml-sai mt-sai mb-sai max-h-screen w-60 sm:flex-shrink-0 flex-col gap-1 bg-base-300 z-nav hidden md:flex",
|
||||||
props.class,
|
props.class,
|
||||||
)}>
|
)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -21,36 +21,22 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
|
||||||
import {fade} from "@lib/transition"
|
import {fade} from "@lib/transition"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
|
||||||
const {
|
const {children, href = "", notification = false, replaceState = false, ...restProps} = $props()
|
||||||
children,
|
|
||||||
href = "",
|
|
||||||
title = "",
|
|
||||||
notification = false,
|
|
||||||
replaceState = false,
|
|
||||||
...restProps
|
|
||||||
} = $props()
|
|
||||||
|
|
||||||
const active = $derived($page.url.pathname === href)
|
const active = $derived($page.url.pathname === href)
|
||||||
const wrapperClass = $derived(
|
|
||||||
cx(restProps.class, "relative flex flex-shrink-0 items-center gap-3 text-left transition-all", {
|
|
||||||
"hover:bg-base-100 hover:text-base-content": true,
|
|
||||||
"text-base-content bg-base-100": active,
|
|
||||||
"tooltip tooltip-right": title,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
data-tip={title}
|
|
||||||
data-sveltekit-replacestate={replaceState}
|
data-sveltekit-replacestate={replaceState}
|
||||||
class={wrapperClass}>
|
class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
||||||
|
class:text-base-content={active}
|
||||||
|
class:bg-base-100={active}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if notification}
|
{#if notification}
|
||||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||||
@@ -58,7 +44,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button {...restProps} data-tip={title} class={wrapperClass}>
|
<button
|
||||||
|
{...restProps}
|
||||||
|
class="{restProps.class} relative flex flex-shrink-0 w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
||||||
|
class:text-base-content={active}
|
||||||
|
class:bg-base-100={active}>
|
||||||
{#if notification}
|
{#if notification}
|
||||||
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
<div class="absolute right-[1.15rem] top-5 h-2 w-2 rounded-full bg-primary" transition:fade>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
const {...props}: Props = $props()
|
const {...props}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 px-2 py-2 {props.class}">
|
<div class="flex flex-col gap-1 px-2 py-4 {props.class}">
|
||||||
{@render props.children?.()}
|
{@render props.children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page class="cw-full">
|
||||||
<ContentSearch>
|
<ContentSearch>
|
||||||
{#snippet input()}
|
{#snippet input()}
|
||||||
<label class="row-2 input input-bordered">
|
<label class="row-2 input input-bordered">
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button class="center btn btn-circle btn-neutral -mr-2 -mt-2 h-12 w-12" onclick={startEdit}>
|
<Button class="center btn btn-circle btn-neutral -mr-4 -mt-4 h-12 w-12" onclick={startEdit}>
|
||||||
<Icon icon={PenNewSquare} />
|
<Icon icon={PenNewSquare} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,8 +180,8 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page class="cw-full">
|
||||||
<PageBar>
|
<PageBar class="cw-full">
|
||||||
{#if showSearch}
|
{#if showSearch}
|
||||||
<label class="input input-bordered input-sm flex flex-1 items-center gap-2" in:fly>
|
<label class="input input-bordered input-sm flex flex-1 items-center gap-2" in:fly>
|
||||||
<Icon icon={Magnifier} />
|
<Icon icon={Magnifier} />
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</PageBar>
|
</PageBar>
|
||||||
<PageContent class="flex flex-col gap-2 p-2 pt-4">
|
<PageContent class="cw-full flex flex-col gap-2 p-2 pt-4">
|
||||||
<div class="flex flex-col gap-2" bind:this={element}>
|
<div class="flex flex-col gap-2" bind:this={element}>
|
||||||
{#each PLATFORM_RELAYS as url (url)}
|
{#each PLATFORM_RELAYS as url (url)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
<svelte:window bind:innerWidth={width} />
|
<svelte:window bind:innerWidth={width} />
|
||||||
|
|
||||||
{#if width <= md}
|
{#if width <= md}
|
||||||
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-2">
|
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-4">
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
</div>
|
</div>
|
||||||
<SecondaryNav class="!flex !w-auto flex-grow pb-16">
|
<SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
|
||||||
<SpaceMenu {url} />
|
<SpaceMenu {url} />
|
||||||
</SecondaryNav>
|
</SecondaryNav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import type {Snippet} from "svelte"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
const {children}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#key $page.url.searchParams.get("at")}
|
{#key $page.url.searchParams.get("at")}
|
||||||
<slot />
|
{@render children?.()}
|
||||||
{/key}
|
{/key}
|
||||||
|
|||||||
@@ -8,11 +8,18 @@
|
|||||||
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
import {now, ifLet, int, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
|
||||||
import type {MakeNonOptional} from "@welshman/lib"
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {makeEvent, makeRoomMeta, MESSAGE, ROOM_ADD_MEMBER} from "@welshman/util"
|
import {
|
||||||
|
makeEvent,
|
||||||
|
makeRoomMeta,
|
||||||
|
MESSAGE,
|
||||||
|
ROOM_ADD_MEMBER,
|
||||||
|
ROOM_REMOVE_MEMBER,
|
||||||
|
} from "@welshman/util"
|
||||||
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
|
||||||
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
import Login2 from "@assets/icons/login-3.svg?dataurl"
|
||||||
|
import cx from "classnames"
|
||||||
import {slide, fade, fly} from "@lib/transition"
|
import {slide, fade, fly} from "@lib/transition"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Divider from "@lib/components/Divider.svelte"
|
import Divider from "@lib/components/Divider.svelte"
|
||||||
@@ -30,6 +37,7 @@
|
|||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||||
|
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
||||||
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
|
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
|
||||||
import {
|
import {
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
@@ -43,7 +51,15 @@
|
|||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import {VoiceState, voiceState} from "@app/voice"
|
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
||||||
|
import {
|
||||||
|
VoiceState,
|
||||||
|
currentVoiceRoom,
|
||||||
|
videoTileCount,
|
||||||
|
voiceMobileRoomPanel,
|
||||||
|
voiceDesktopRoomPanel,
|
||||||
|
voiceState,
|
||||||
|
} from "@app/voice"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
import {checked} from "@app/util/notifications"
|
||||||
@@ -56,6 +72,50 @@
|
|||||||
const url = decodeRelay(relay)
|
const url = decodeRelay(relay)
|
||||||
const room = deriveRoom(url, h)
|
const room = deriveRoom(url, h)
|
||||||
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
||||||
|
|
||||||
|
const voiceConnectedHere = $derived(
|
||||||
|
isVoiceRoom &&
|
||||||
|
$voiceState === VoiceState.Connected &&
|
||||||
|
$currentVoiceRoom?.url === url &&
|
||||||
|
$currentVoiceRoom?.h === h,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showMobileVideoPanel = $derived(
|
||||||
|
isVoiceRoom && $voiceState === VoiceState.Connected && $voiceMobileRoomPanel === "video",
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageContentFrame = $derived<"default" | "split-right">(
|
||||||
|
voiceConnectedHere && $voiceDesktopRoomPanel === "split" ? "split-right" : "default",
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageContentHiddenDesktopVideoOnly = $derived(
|
||||||
|
voiceConnectedHere && $voiceDesktopRoomPanel === "video",
|
||||||
|
)
|
||||||
|
|
||||||
|
let prevVideoTileCount = $state(0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($voiceState !== VoiceState.Connected) {
|
||||||
|
voiceMobileRoomPanel.set("chat")
|
||||||
|
voiceDesktopRoomPanel.set("chat")
|
||||||
|
prevVideoTileCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = isVoiceRoom && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h
|
||||||
|
const n = $videoTileCount
|
||||||
|
|
||||||
|
if (!here) {
|
||||||
|
prevVideoTileCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevVideoTileCount === 0 && n >= 1) {
|
||||||
|
voiceDesktopRoomPanel.set("video")
|
||||||
|
voiceMobileRoomPanel.set("video")
|
||||||
|
}
|
||||||
|
prevVideoTileCount = n
|
||||||
|
})
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||||
@@ -225,6 +285,8 @@
|
|||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
|
let chatCompose: HTMLElement | undefined = $state()
|
||||||
|
let dynamicPadding: HTMLElement | undefined = $state()
|
||||||
let newMessagesSeen = false
|
let newMessagesSeen = false
|
||||||
let showFixedNewMessages = $state(false)
|
let showFixedNewMessages = $state(false)
|
||||||
let showScrollButton = $state(false)
|
let showScrollButton = $state(false)
|
||||||
@@ -279,7 +341,7 @@
|
|||||||
showPubkey:
|
showPubkey:
|
||||||
previousPubkey !== event.pubkey ||
|
previousPubkey !== event.pubkey ||
|
||||||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
||||||
previousKind === ROOM_ADD_MEMBER,
|
[ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(previousKind!),
|
||||||
})
|
})
|
||||||
|
|
||||||
previousDate = date
|
previousDate = date
|
||||||
@@ -304,7 +366,7 @@
|
|||||||
url,
|
url,
|
||||||
at: at || now(),
|
at: at || now(),
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}],
|
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
||||||
onBackwardExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loadingBackward = false
|
loadingBackward = false
|
||||||
},
|
},
|
||||||
@@ -335,9 +397,22 @@
|
|||||||
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (dynamicPadding && chatCompose) {
|
||||||
|
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(chatCompose!)
|
||||||
|
observer.observe(dynamicPadding!)
|
||||||
|
|
||||||
start()
|
start()
|
||||||
|
|
||||||
return cleanup
|
return () => {
|
||||||
|
cleanup()
|
||||||
|
observer.unobserve(chatCompose!)
|
||||||
|
observer.unobserve(dynamicPadding!)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -354,7 +429,17 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
<PageContent
|
||||||
|
bind:element
|
||||||
|
onscroll={onScroll}
|
||||||
|
contentFrame={pageContentFrame}
|
||||||
|
class={cx(
|
||||||
|
showMobileVideoPanel
|
||||||
|
? "hidden flex-col-reverse pt-4 md:flex md:flex-col-reverse"
|
||||||
|
: "flex flex-col-reverse pt-4",
|
||||||
|
pageContentHiddenDesktopVideoOnly && "md:hidden",
|
||||||
|
)}>
|
||||||
|
<div bind:this={dynamicPadding}></div>
|
||||||
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||||
<div class="py-20">
|
<div class="py-20">
|
||||||
<div class="card2 col-8 m-auto max-w-md items-center text-center">
|
<div class="card2 col-8 m-auto max-w-md items-center text-center">
|
||||||
@@ -398,6 +483,8 @@
|
|||||||
{@const event = $state.snapshot(value as TrustedEvent)}
|
{@const event = $state.snapshot(value as TrustedEvent)}
|
||||||
{#if event.kind === ROOM_ADD_MEMBER}
|
{#if event.kind === ROOM_ADD_MEMBER}
|
||||||
<RoomItemAddMember {url} {event} />
|
<RoomItemAddMember {url} {event} />
|
||||||
|
{:else if event.kind === ROOM_REMOVE_MEMBER}
|
||||||
|
<RoomItemRemoveMember {url} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<div in:slide class="cv" class:-mt-1={!showPubkey}>
|
<div in:slide class="cv" class:-mt-1={!showPubkey}>
|
||||||
<RoomItem
|
<RoomItem
|
||||||
@@ -419,10 +506,29 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="h-screen"></div>
|
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0">
|
{#if voiceConnectedHere}
|
||||||
|
<VideoCallContent
|
||||||
|
variant="desktop-split"
|
||||||
|
{url}
|
||||||
|
{h}
|
||||||
|
visible={$voiceDesktopRoomPanel === "split"} />
|
||||||
|
<VideoCallContent variant="desktop-full" {url} {h} visible={$voiceDesktopRoomPanel === "video"} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isVoiceRoom && $voiceState === VoiceState.Connected}
|
||||||
|
<VideoCallContent variant="mobile" {url} {h} visible={$voiceMobileRoomPanel === "video"} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cx(
|
||||||
|
"chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0",
|
||||||
|
voiceConnectedHere && $voiceDesktopRoomPanel === "split" && "cw-split-chat",
|
||||||
|
pageContentHiddenDesktopVideoOnly && "md:hidden",
|
||||||
|
showMobileVideoPanel && "max-md:hidden",
|
||||||
|
)}
|
||||||
|
bind:this={chatCompose}>
|
||||||
<div class="chat__compose-inner min-w-0 flex-1">
|
<div class="chat__compose-inner min-w-0 flex-1">
|
||||||
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||||
<!-- pass -->
|
<!-- pass -->
|
||||||
@@ -470,7 +576,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
||||||
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
<div
|
||||||
|
class={cx("hide-on-keyboard flex-shrink-0 p-2 md:hidden", showMobileVideoPanel && "hidden")}>
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {now, int, ifLet, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
|
import {now, int, ifLet, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER} from "@welshman/util"
|
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
|
||||||
import {pubkey, publishThunk} from "@welshman/app"
|
import {pubkey, publishThunk} from "@welshman/app"
|
||||||
import {fade, fly} from "@lib/transition"
|
import {fade, fly} from "@lib/transition"
|
||||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
import SpaceSearch from "@app/components/SpaceSearch.svelte"
|
||||||
import RoomItem from "@app/components/RoomItem.svelte"
|
import RoomItem from "@app/components/RoomItem.svelte"
|
||||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
|
import RoomItemRemoveMember from "@src/app/components/RoomItemRemoveMember.svelte"
|
||||||
import RoomCompose from "@app/components/RoomCompose.svelte"
|
import RoomCompose from "@app/components/RoomCompose.svelte"
|
||||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||||
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
import RoomComposeParent from "@app/components/RoomComposeParent.svelte"
|
||||||
@@ -163,6 +163,8 @@
|
|||||||
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
let share = $state(popKey<TrustedEvent | undefined>("share"))
|
||||||
let parent: TrustedEvent | undefined = $state()
|
let parent: TrustedEvent | undefined = $state()
|
||||||
let element: HTMLElement | undefined = $state()
|
let element: HTMLElement | undefined = $state()
|
||||||
|
let chatCompose: HTMLElement | undefined = $state()
|
||||||
|
let dynamicPadding: HTMLElement | undefined = $state()
|
||||||
let newMessagesSeen = false
|
let newMessagesSeen = false
|
||||||
let showFixedNewMessages = $state(false)
|
let showFixedNewMessages = $state(false)
|
||||||
let showScrollButton = $state(false)
|
let showScrollButton = $state(false)
|
||||||
@@ -217,7 +219,7 @@
|
|||||||
showPubkey:
|
showPubkey:
|
||||||
previousPubkey !== event.pubkey ||
|
previousPubkey !== event.pubkey ||
|
||||||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
||||||
previousKind === RELAY_ADD_MEMBER,
|
[RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER].includes(previousKind!),
|
||||||
})
|
})
|
||||||
|
|
||||||
previousDate = date
|
previousDate = date
|
||||||
@@ -242,7 +244,7 @@
|
|||||||
url,
|
url,
|
||||||
at: at || now(),
|
at: at || now(),
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}],
|
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
||||||
onBackwardExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loadingBackward = false
|
loadingBackward = false
|
||||||
},
|
},
|
||||||
@@ -273,9 +275,24 @@
|
|||||||
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
const onEditPrevious = () => ifLet($events.toReversed().find(canEditEvent), onEditEvent)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (dynamicPadding && chatCompose) {
|
||||||
|
dynamicPadding!.style.minHeight = `${chatCompose!.offsetHeight}px`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(chatCompose!)
|
||||||
|
observer.observe(dynamicPadding!)
|
||||||
start()
|
start()
|
||||||
|
|
||||||
return cleanup
|
return () => {
|
||||||
|
cleanup()
|
||||||
|
controller.abort()
|
||||||
|
observer.unobserve(chatCompose!)
|
||||||
|
observer.unobserve(dynamicPadding!)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -289,7 +306,8 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 mb-14 md:mb-0">
|
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||||
|
<div bind:this={dynamicPadding}></div>
|
||||||
{#if loadingForward}
|
{#if loadingForward}
|
||||||
<p class="py-20 flex justify-center">
|
<p class="py-20 flex justify-center">
|
||||||
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
|
||||||
@@ -311,6 +329,8 @@
|
|||||||
{@const event = $state.snapshot(value as TrustedEvent)}
|
{@const event = $state.snapshot(value as TrustedEvent)}
|
||||||
{#if event.kind === RELAY_ADD_MEMBER}
|
{#if event.kind === RELAY_ADD_MEMBER}
|
||||||
<RoomItemAddMember {url} {event} />
|
<RoomItemAddMember {url} {event} />
|
||||||
|
{:else if event.kind === RELAY_REMOVE_MEMBER}
|
||||||
|
<RoomItemRemoveMember {url} {event} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class:-mt-1={!showPubkey}>
|
<div class:-mt-1={!showPubkey}>
|
||||||
<RoomItem
|
<RoomItem
|
||||||
@@ -331,10 +351,9 @@
|
|||||||
<Spinner>End of message history</Spinner>
|
<Spinner>End of message history</Spinner>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<div class="h-screen"></div>
|
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200">
|
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {readable} from "svelte/store"
|
|
||||||
import type {Readable} from "svelte/store"
|
|
||||||
import {page} from "$app/stores"
|
|
||||||
import {sortBy, partition, spec, pushToMapKey, max} from "@welshman/lib"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
|
||||||
import {getTagValue} from "@welshman/util"
|
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import PollIcon from "@assets/icons/revote.svg?dataurl"
|
|
||||||
import Add from "@assets/icons/add.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
|
||||||
import PollItem from "@app/components/PollItem.svelte"
|
|
||||||
import PollCreate from "@app/components/PollCreate.svelte"
|
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
import {decodeRelay, makeCommentFilter} from "@app/core/state"
|
|
||||||
import {makeFeed} from "@app/core/requests"
|
|
||||||
import {pushModal} from "@app/util/modal"
|
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
|
||||||
|
|
||||||
let loading = $state(true)
|
|
||||||
let element: HTMLElement | undefined = $state()
|
|
||||||
let events: Readable<TrustedEvent[]> = $state(readable([]))
|
|
||||||
|
|
||||||
const createPoll = () => pushModal(PollCreate, {url})
|
|
||||||
|
|
||||||
const items = $derived.by(() => {
|
|
||||||
const scores = new Map<string, number[]>()
|
|
||||||
const [polls, comments] = partition(spec({kind: Poll}), $events)
|
|
||||||
|
|
||||||
for (const comment of comments) {
|
|
||||||
const id = getTagValue("E", comment.tags)
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
pushToMapKey(scores, id, comment.created_at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), polls)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const feed = makeFeed({
|
|
||||||
url,
|
|
||||||
element: element!,
|
|
||||||
filters: [{kinds: [Poll]}, makeCommentFilter([Poll])],
|
|
||||||
onBackwardExhausted: () => {
|
|
||||||
loading = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
events = feed.events
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
feed.cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SpaceBar>
|
|
||||||
{#snippet title()}
|
|
||||||
<Icon icon={PollIcon} />
|
|
||||||
<strong>Polls</strong>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet action()}
|
|
||||||
<Button class="btn btn-primary btn-sm" onclick={createPoll}>
|
|
||||||
<Icon icon={Add} />
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</SpaceBar>
|
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2 pt-4">
|
|
||||||
{#each items as event (event.id)}
|
|
||||||
<div in:fly>
|
|
||||||
<PollItem {url} event={$state.snapshot(event)} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
|
||||||
<Spinner {loading}>
|
|
||||||
{#if loading}
|
|
||||||
Looking for polls...
|
|
||||||
{:else if items.length === 0}
|
|
||||||
No polls found.
|
|
||||||
{:else}
|
|
||||||
That's all!
|
|
||||||
{/if}
|
|
||||||
</Spinner>
|
|
||||||
</p>
|
|
||||||
</PageContent>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {onMount} from "svelte"
|
|
||||||
import {page} from "$app/stores"
|
|
||||||
import {sleep} from "@welshman/lib"
|
|
||||||
import type {MakeNonOptional} from "@welshman/lib"
|
|
||||||
import {COMMENT} from "@welshman/util"
|
|
||||||
import {repository} from "@welshman/app"
|
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
|
|
||||||
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
|
||||||
import CommentActions from "@app/components/CommentActions.svelte"
|
|
||||||
import EventReply from "@app/components/EventReply.svelte"
|
|
||||||
import {deriveEvent, decodeRelay} from "@app/core/state"
|
|
||||||
import {Poll, PollResponse} from "nostr-tools/kinds"
|
|
||||||
|
|
||||||
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
|
||||||
const url = decodeRelay(relay)
|
|
||||||
const event = deriveEvent(id, [url])
|
|
||||||
const filters = [{kinds: [COMMENT], "#E": [id]}]
|
|
||||||
const comments = deriveEventsAsc(deriveEventsById({repository, filters}))
|
|
||||||
|
|
||||||
const back = () => history.back()
|
|
||||||
|
|
||||||
const openReply = () => {
|
|
||||||
showReply = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeReply = () => {
|
|
||||||
showReply = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const expand = () => {
|
|
||||||
showAll = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let showAll = $state(false)
|
|
||||||
let showReply = $state(false)
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
|
|
||||||
request({
|
|
||||||
relays: [url],
|
|
||||||
filters: [{kinds: [Poll], ids: [id]}, {kinds: [PollResponse], "#e": [id]}, ...filters],
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
controller.abort()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SpaceBar {back}>
|
|
||||||
{#snippet title()}
|
|
||||||
<h1 class="text-xl">{$event?.content || "Poll"}</h1>
|
|
||||||
{/snippet}
|
|
||||||
</SpaceBar>
|
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-3 p-2 pt-4">
|
|
||||||
{#if $event}
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
|
||||||
<div class="col-3 ml-12 flex flex-col gap-3">
|
|
||||||
<NoteContent showEntire event={$event} {url} />
|
|
||||||
<CommentActions segment="polls" showActivity {url} event={$event} />
|
|
||||||
</div>
|
|
||||||
</NoteCard>
|
|
||||||
{#if !showAll && $comments.length > 4}
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Button class="btn btn-link" onclick={expand}>
|
|
||||||
<Icon icon={SortVertical} />
|
|
||||||
Show all {$comments.length} comments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each $comments.slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
|
||||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
|
||||||
<div class="col-3 ml-12">
|
|
||||||
<NoteContent showEntire event={reply} {url} />
|
|
||||||
<CommentActions segment="polls" event={reply} {url} />
|
|
||||||
</div>
|
|
||||||
</NoteCard>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if showReply}
|
|
||||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
|
||||||
{:else}
|
|
||||||
<div class="flex justify-end p-2">
|
|
||||||
<Button class="btn btn-primary" onclick={openReply}>Comment on this poll</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{#await sleep(5000)}
|
|
||||||
<Spinner loading>Loading poll...</Spinner>
|
|
||||||
{:then}
|
|
||||||
<p>Failed to load poll.</p>
|
|
||||||
{/await}
|
|
||||||
{/if}
|
|
||||||
</PageContent>
|
|
||||||
@@ -1,23 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {tick, onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {debounce} from "throttle-debounce"
|
import {groupBy, ago, MONTH, first, sortBy, uniqBy} from "@welshman/lib"
|
||||||
import {
|
|
||||||
formatTimestampAsDate,
|
|
||||||
groupBy,
|
|
||||||
ago,
|
|
||||||
now,
|
|
||||||
MONTH,
|
|
||||||
MINUTE,
|
|
||||||
HOUR,
|
|
||||||
DAY,
|
|
||||||
WEEK,
|
|
||||||
first,
|
|
||||||
sortBy,
|
|
||||||
uniqBy,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {
|
import {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
THREAD,
|
THREAD,
|
||||||
@@ -28,17 +13,12 @@
|
|||||||
getTagValue,
|
getTagValue,
|
||||||
getTagValues,
|
getTagValues,
|
||||||
getIdAndAddress,
|
getIdAndAddress,
|
||||||
sortEventsDesc,
|
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import History from "@assets/icons/history.svg?dataurl"
|
import History from "@assets/icons/history.svg?dataurl"
|
||||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
|
||||||
import {createScroller} from "@lib/html"
|
import {createScroller} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import NoteItem from "@app/components/NoteItem.svelte"
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
@@ -46,11 +26,8 @@
|
|||||||
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
|
||||||
import GoalItem from "@app/components/GoalItem.svelte"
|
import GoalItem from "@app/components/GoalItem.svelte"
|
||||||
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
|
||||||
import PollItem from "@app/components/PollItem.svelte"
|
|
||||||
import RecentConversation from "@app/components/RecentConversation.svelte"
|
import RecentConversation from "@app/components/RecentConversation.svelte"
|
||||||
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
import {decodeRelay, deriveEventsForUrl, CONTENT_KINDS} from "@app/core/state"
|
||||||
import {goToEvent} from "@app/util/routes"
|
|
||||||
import {Poll} from "nostr-tools/kinds"
|
|
||||||
|
|
||||||
const url = decodeRelay($page.params.relay!)
|
const url = decodeRelay($page.params.relay!)
|
||||||
const since = ago(3, MONTH)
|
const since = ago(3, MONTH)
|
||||||
@@ -111,93 +88,9 @@
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
let term = $state("")
|
|
||||||
let showSearch = $state(false)
|
|
||||||
let loading = $state(false)
|
|
||||||
let searchResults: TrustedEvent[] = $state([])
|
|
||||||
let searchInput: HTMLInputElement | undefined = $state()
|
|
||||||
let controller: AbortController | undefined
|
|
||||||
|
|
||||||
let limit = $state(20)
|
let limit = $state(20)
|
||||||
let element: Element | undefined = $state()
|
let element: Element | undefined = $state()
|
||||||
|
|
||||||
const resultsByAge = $derived(groupBy(e => getAgeSection(e.created_at), searchResults))
|
|
||||||
|
|
||||||
const getAgeSection = (createdAt: number) => {
|
|
||||||
const age = now() - createdAt
|
|
||||||
|
|
||||||
if (age <= DAY) return "day"
|
|
||||||
if (age <= WEEK) return "week"
|
|
||||||
return "older"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAgeLabel = (createdAt: number) => {
|
|
||||||
const age = now() - createdAt
|
|
||||||
|
|
||||||
if (age < MINUTE) return "Just now"
|
|
||||||
if (age < HOUR) return `${Math.floor(age / MINUTE)}m ago`
|
|
||||||
if (age < DAY) return `${Math.floor(age / HOUR)}h ago`
|
|
||||||
return `${Math.floor(age / DAY)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSearch = () => {
|
|
||||||
showSearch = true
|
|
||||||
tick().then(() => searchInput?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeSearch = () => {
|
|
||||||
showSearch = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
term = ""
|
|
||||||
showSearch = false
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
controller?.abort()
|
|
||||||
controller = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = debounce(300, async (searchTerm: string) => {
|
|
||||||
controller?.abort()
|
|
||||||
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
loading = false
|
|
||||||
searchResults = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
controller = new AbortController()
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await request({
|
|
||||||
relays: [url],
|
|
||||||
autoClose: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
filters: [{kinds: [MESSAGE, ...CONTENT_KINDS], search: searchTerm.trim()}],
|
|
||||||
})
|
|
||||||
|
|
||||||
searchResults = sortEventsDesc(uniqBy((e: TrustedEvent) => e.id, events))
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
|
||||||
searchResults = []
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onInput = () => {
|
|
||||||
showSearch = true
|
|
||||||
void search(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onResultClick = (event: TrustedEvent) => {
|
|
||||||
closeSearch()
|
|
||||||
goToEvent(event, {keepFocus: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const scroller = createScroller({
|
const scroller = createScroller({
|
||||||
element: element!,
|
element: element!,
|
||||||
@@ -215,101 +108,28 @@
|
|||||||
<Icon icon={History} />
|
<Icon icon={History} />
|
||||||
<strong>Recent Activity</strong>
|
<strong>Recent Activity</strong>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet action()}
|
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={openSearch}>
|
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
</button>
|
|
||||||
{#if showSearch}
|
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={closeSearch}
|
|
||||||
></button>
|
|
||||||
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
|
||||||
<div
|
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<strong>Search</strong>
|
|
||||||
<Button onclick={clearSearch}>
|
|
||||||
<Icon icon={CloseCircle} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
|
||||||
<Icon size={4} icon={Magnifier} />
|
|
||||||
<input
|
|
||||||
bind:this={searchInput}
|
|
||||||
bind:value={term}
|
|
||||||
class="min-w-0 grow"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search this space..."
|
|
||||||
oninput={onInput} />
|
|
||||||
</label>
|
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
|
||||||
{#if !term}
|
|
||||||
<p class="text-sm opacity-70">Search for content across this space.</p>
|
|
||||||
{:else if loading}
|
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
|
||||||
{:else if resultsByAge.size === 0}
|
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="col-2">
|
|
||||||
{#each resultsByAge as [key, events] (key)}
|
|
||||||
<div class="col-2">
|
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
|
||||||
{#if key === "day"}
|
|
||||||
Last 24 Hours
|
|
||||||
{:else if key === "week"}
|
|
||||||
Last 7 Days
|
|
||||||
{:else}
|
|
||||||
Older
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="col-2">
|
|
||||||
{#each events as event (event.id)}
|
|
||||||
<button
|
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
|
||||||
onclick={() => onResultClick(event)}>
|
|
||||||
<p class="line-clamp-2 text-sm">
|
|
||||||
{event.content.trim() ||
|
|
||||||
getTagValue("title", event.tags) ||
|
|
||||||
"(No text content)"}
|
|
||||||
</p>
|
|
||||||
<div class="row-2 text-xs opacity-70">
|
|
||||||
<span>{getAgeLabel(event.created_at)}</span>
|
|
||||||
<span>{formatTimestampAsDate(event.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
<div bind:this={element}>
|
||||||
{#if $recentActivity.length === 0}
|
<PageContent class="flex flex-col gap-2 p-2 pt-4">
|
||||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
{#if $recentActivity.length === 0}
|
||||||
{:else}
|
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||||
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
{:else}
|
||||||
{#if type === "message"}
|
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
||||||
<RecentConversation {url} {event} {count} />
|
{#if type === "message"}
|
||||||
{:else if event.kind === THREAD}
|
<RecentConversation {url} {event} {count} />
|
||||||
<ThreadItem {url} {event} />
|
{:else if event.kind === THREAD}
|
||||||
{:else if event.kind === CLASSIFIED}
|
<ThreadItem {url} {event} />
|
||||||
<ClassifiedItem {url} {event} />
|
{:else if event.kind === CLASSIFIED}
|
||||||
{:else if event.kind === ZAP_GOAL}
|
<ClassifiedItem {url} {event} />
|
||||||
<GoalItem {url} {event} />
|
{:else if event.kind === ZAP_GOAL}
|
||||||
{:else if event.kind === EVENT_TIME}
|
<GoalItem {url} {event} />
|
||||||
<CalendarEventItem {url} {event} />
|
{:else if event.kind === EVENT_TIME}
|
||||||
{:else if event.kind === Poll}
|
<CalendarEventItem {url} {event} />
|
||||||
<PollItem {url} {event} />
|
{:else}
|
||||||
{:else}
|
<NoteItem {url} {event} />
|
||||||
<NoteItem {url} {event} />
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
{/if}
|
</PageContent>
|
||||||
</PageContent>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<Page class="cw-full">
|
||||||
<PageContent class="flex flex-col items-center gap-2 p-2 pt-4">
|
<PageContent class="cw-full flex flex-col items-center gap-2 p-2 pt-4">
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<div>Create your own Space</div>
|
<div>Create your own Space</div>
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ config({path: ".env"})
|
|||||||
export default {
|
export default {
|
||||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||||
darkMode: ["selector", '[data-theme="dark"]'],
|
darkMode: ["selector", '[data-theme="dark"]'],
|
||||||
safelist: ["bg-success", "bg-warning", "w-4 h-4"],
|
safelist: ["bg-success", "bg-warning", 'w-4 h-4'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
@@ -21,7 +21,7 @@ export default {
|
|||||||
nav: 5,
|
nav: 5,
|
||||||
popover: 6,
|
popover: 6,
|
||||||
modal: 7,
|
modal: 7,
|
||||||
tooltip: 8,
|
"modal-feature": 8,
|
||||||
toast: 9,
|
toast: 9,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user