Compare commits
13 Commits
video-demo
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0547e9513f | |||
| 70e5172f1b | |||
| 61c568a112 | |||
| ae2ba6f44d | |||
| f84006fbe4 | |||
| fed34a2747 | |||
| 80df16f97b | |||
| 18cb245599 | |||
| fd6cc84be6 | |||
| 9311cab3b2 | |||
| fceccf47be | |||
| fe20fbfd28 | |||
| 4f3a2a1660 |
+12
-67
@@ -50,7 +50,6 @@
|
|||||||
--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] {
|
||||||
@@ -365,6 +364,14 @@
|
|||||||
|
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
@@ -391,57 +398,12 @@ progress[value]::-webkit-progress-value {
|
|||||||
|
|
||||||
/* content width for fixed elements */
|
/* content width for fixed elements */
|
||||||
|
|
||||||
.cw {
|
.left-content {
|
||||||
@apply w-full md:left-[18.5rem] md:w-[calc(100%-18.5rem-var(--sair))];
|
@apply md:left-[calc(18.5rem+var(--sail))];
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -449,30 +411,13 @@ body.keyboard-open .hide-on-keyboard {
|
|||||||
/* chat view */
|
/* chat view */
|
||||||
|
|
||||||
.chat__compose {
|
.chat__compose {
|
||||||
@apply cb cw fixed z-compose;
|
@apply relative z-compose mb-14 flex-grow md:mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat__compose-zone {
|
.chat__compose .chat__compose-inner {
|
||||||
@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,8 +196,6 @@
|
|||||||
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 = []
|
||||||
@@ -233,20 +231,6 @@
|
|||||||
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(() => {
|
||||||
@@ -294,7 +278,6 @@
|
|||||||
</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">
|
||||||
@@ -335,9 +318,10 @@
|
|||||||
</Spinner>
|
</Spinner>
|
||||||
{@render info?.()}
|
{@render info?.()}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="h-screen"></div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
<div class="chat__compose bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<ChatComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
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"
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
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
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
|
|
||||||
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(() => {
|
||||||
@@ -60,4 +64,10 @@
|
|||||||
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,7 +64,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={spacer}></div>
|
<div bind:this={spacer}></div>
|
||||||
<form in:fly bind:this={form} onsubmit={preventDefault(submit)} class="cb cw fixed z-feature pt-3">
|
<form
|
||||||
|
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,10 +1,12 @@
|
|||||||
<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()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<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,10 +1,12 @@
|
|||||||
<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()
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<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}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<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,18 +32,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="ml-sai mt-sai mb-sai relative z-nav hidden w-14 flex-shrink-0 bg-base-200 pt-4 md:block">
|
class="ml-sai mt-sai mb-sai relative z-popover isolate hidden w-14 flex-shrink-0 bg-base-200 pt-2 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>
|
<div class="flex flex-col">
|
||||||
<PrimaryNavItem
|
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
|
||||||
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}
|
||||||
@@ -53,11 +49,10 @@
|
|||||||
<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" class="tooltip-right">
|
<PrimaryNavItem title="Search" href="/people">
|
||||||
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
<ImageIcon alt="Search" src={Magnifier} size={8} />
|
||||||
</PrimaryNavItem>
|
</PrimaryNavItem>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +63,10 @@
|
|||||||
|
|
||||||
<!-- a little extra something for ios -->
|
<!-- a little extra something for ios -->
|
||||||
<div
|
<div
|
||||||
class="bottom-nav hide-on-keyboard fixed bottom-0 left-0 right-0 z-nav h-[var(--saib)] bg-base-100 md:hidden">
|
class="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="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">
|
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">
|
||||||
<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 {displayRelayUrl} from "@welshman/util"
|
import {deriveRelayDisplay} from "@welshman/app"
|
||||||
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,11 +12,13 @@
|
|||||||
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={displayRelayUrl(url)}
|
title={$display}
|
||||||
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((windowHeight - navPadding) / itemHeight)
|
const itemLimit = $derived(Math.max(0, (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" class="tooltip-right">
|
<PrimaryNavItem title="Home" href="/home">
|
||||||
<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,7 +33,6 @@
|
|||||||
<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} from "@app/core/state"
|
import {REACTION_KINDS, deriveUserIsSpaceAdmin} from "@app/core/state"
|
||||||
import {pushModal} from "@app/util/modal"
|
import {pushModal} from "@app/util/modal"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -78,6 +78,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())))
|
||||||
@@ -118,7 +120,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}
|
{#if url && $reports.length > 0 && $userIsAdmin}
|
||||||
<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,6 +22,7 @@
|
|||||||
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"
|
||||||
@@ -206,39 +207,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}
|
||||||
<Button
|
<Tooltip content="Only members can send messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can send messages.">
|
<Icon size={4} icon={Microphone} /> Restricted
|
||||||
<Icon size={4} icon={Microphone} /> Restricted
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isPrivate}
|
{#if $room?.isPrivate}
|
||||||
<Button
|
<Tooltip content="Only members can view messages.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Only members can view messages.">
|
<Icon size={4} icon={Lock} /> Private
|
||||||
<Icon size={4} icon={Lock} /> Private
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isHidden}
|
{#if $room?.isHidden}
|
||||||
<Button
|
<Tooltip content="This room is not visible to non-members.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room is not visible to non-members.">
|
<Icon size={4} icon={EyeClosed} /> Hidden
|
||||||
<Icon size={4} icon={EyeClosed} /> Hidden
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $room?.isClosed}
|
{#if $room?.isClosed}
|
||||||
<Button
|
<Tooltip content="Requests to join this room will be ignored.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="Requests to join this room will be ignored.">
|
<Icon size={4} icon={MinusCircle} /> Closed
|
||||||
<Icon size={4} icon={MinusCircle} /> Closed
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
{#if !$room?.isRestricted && !$room?.isPrivate && !$room?.isHidden && !$room?.isClosed}
|
||||||
<Button
|
<Tooltip content="This room has no additional access controls.">
|
||||||
class="btn btn-neutral btn-xs rounded-full tooltip flex gap-2 items-center"
|
<Button class="btn btn-neutral btn-xs rounded-full flex gap-2 items-center">
|
||||||
data-tip="This room has no additional access controls.">
|
<Icon size={4} icon={Eye} /> Public
|
||||||
<Icon size={4} icon={Eye} /> Public
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<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,7 +1,8 @@
|
|||||||
<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 {deriveRelay, createSearch, pubkey} from "@welshman/app"
|
import {Poll} from "nostr-tools/kinds"
|
||||||
|
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"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
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"
|
||||||
@@ -64,11 +66,13 @@
|
|||||||
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)
|
||||||
@@ -141,7 +145,9 @@
|
|||||||
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 class="flex items-center gap-1 relative">
|
<strong
|
||||||
|
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"
|
||||||
@@ -257,6 +263,11 @@
|
|||||||
<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,12 +24,13 @@
|
|||||||
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} {replaceState} {notification}>
|
<SecondaryNavItem href={path} title={roomName} {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,76 +130,74 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
||||||
<button class="btn btn-neutral btn-sm btn-square" aria-label="Search" onclick={open}>
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
</button>
|
||||||
</button>
|
{#if show}
|
||||||
{#if show}
|
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
||||||
<button class="fixed inset-0 z-feature" aria-label="Close search" onclick={close}></button>
|
<div class="fixed top-sai right-sai left-content z-feature p-2">
|
||||||
<div class="fixed cw top-0 right-0 z-feature p-2">
|
<div
|
||||||
<div
|
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
||||||
class="card2 card2-sm bg-alt flex flex-col gap-2 shadow-md"
|
transition:fly={{y: -40, duration: 150}}>
|
||||||
transition:fly={{y: -40, duration: 150}}>
|
<div class="flex justify-between">
|
||||||
<div class="flex justify-between">
|
<strong>Search</strong>
|
||||||
<strong>Search</strong>
|
<Button onclick={clear}>
|
||||||
<Button onclick={clear}>
|
<Icon icon={CloseCircle} />
|
||||||
<Icon icon={CloseCircle} />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
||||||
<label class="input input-sm input-bordered flex w-full items-center gap-2">
|
<Icon size={4} icon={Magnifier} />
|
||||||
<Icon size={4} icon={Magnifier} />
|
<input
|
||||||
<input
|
bind:this={input}
|
||||||
bind:this={input}
|
bind:value={term}
|
||||||
bind:value={term}
|
class="min-w-0 grow"
|
||||||
class="min-w-0 grow"
|
type="text"
|
||||||
type="text"
|
placeholder={h ? "Search this room..." : "Search this space..."}
|
||||||
placeholder={h ? "Search this room..." : "Search this space..."}
|
oninput={onInput} />
|
||||||
oninput={onInput} />
|
</label>
|
||||||
</label>
|
<div class="max-h-[65vh] overflow-y-auto">
|
||||||
<div class="max-h-[65vh] overflow-y-auto">
|
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
||||||
<p class="mb-2 text-xs opacity-70">{relayStatus}</p>
|
{#if !term}
|
||||||
{#if !term}
|
<p class="text-sm opacity-70">
|
||||||
<p class="text-sm opacity-70">
|
{h ? "Search for content in this room." : "Search for content in this space."}
|
||||||
{h ? "Search for content in this room." : "Search for content in this space."}
|
</p>
|
||||||
</p>
|
{:else if loading}
|
||||||
{:else if loading}
|
<p class="text-sm opacity-70">Searching...</p>
|
||||||
<p class="text-sm opacity-70">Searching...</p>
|
{:else if eventsByAge.size === 0}
|
||||||
{:else if eventsByAge.size === 0}
|
<p class="text-sm opacity-70">No results found.</p>
|
||||||
<p class="text-sm opacity-70">No results found.</p>
|
{:else}
|
||||||
{:else}
|
<div class="col-2">
|
||||||
<div class="col-2">
|
{#each eventsByAge as [key, events] (key)}
|
||||||
{#each eventsByAge 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">
|
<div class="col-2">
|
||||||
<p class="text-xs uppercase tracking-wide opacity-60">
|
{#each events as event (event.id)}
|
||||||
{#if key === "day"}
|
<button
|
||||||
Last 24 Hours
|
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
||||||
{:else if key === "week"}
|
onclick={() => onRoomSearchResultClick(event)}>
|
||||||
Last 7 Days
|
<p class="line-clamp-2 text-sm">
|
||||||
{:else}
|
{event.content.trim() || "(No text content)"}
|
||||||
Older
|
</p>
|
||||||
{/if}
|
<div class="row-2 text-xs opacity-70">
|
||||||
</p>
|
<span>{getAgeLabel(event.created_at)}</span>
|
||||||
<div class="col-2">
|
<span>{formatTimestampAsDate(event.created_at)}</span>
|
||||||
{#each events as event (event.id)}
|
</div>
|
||||||
<button
|
</button>
|
||||||
class="card2 bg-alt card2-sm col-2 transition-colors hover:bg-base-200 text-left"
|
{/each}
|
||||||
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>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|||||||
@@ -1,28 +1,98 @@
|
|||||||
<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 transition:fly class="bottom-sai right-sai toast z-toast">
|
<div
|
||||||
|
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 flex justify-center whitespace-normal text-left"
|
class="alert relative 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"}>
|
||||||
<p class:welshman-content-error={theme === "error"}>
|
<Button
|
||||||
|
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}
|
||||||
@@ -35,9 +105,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<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,10 +26,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -37,25 +35,16 @@
|
|||||||
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(() => {
|
||||||
void loadDevices()
|
loadDevices()
|
||||||
const md = navigator.mediaDevices
|
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
||||||
if (!md?.addEventListener) return
|
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
||||||
const onDeviceChange = () => {
|
|
||||||
void loadDevices()
|
|
||||||
}
|
|
||||||
md.addEventListener("devicechange", onDeviceChange)
|
|
||||||
return () => {
|
|
||||||
md.removeEventListener("devicechange", onDeviceChange)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -66,7 +55,6 @@
|
|||||||
}
|
}
|
||||||
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 = () => {
|
||||||
@@ -77,10 +65,6 @@
|
|||||||
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVideoChange = () => {
|
|
||||||
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDone = () => {
|
const onDone = () => {
|
||||||
popModal()
|
popModal()
|
||||||
}
|
}
|
||||||
@@ -92,8 +76,8 @@
|
|||||||
<Modal>
|
<Modal>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Call settings</ModalTitle>
|
<ModalTitle>Audio settings</ModalTitle>
|
||||||
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
<ModalSubtitle>Choose microphone and speaker 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>
|
||||||
@@ -136,25 +120,6 @@
|
|||||||
{/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,18 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import {fade, fly} from "svelte/transition"
|
import {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 Videocamera from "@assets/icons/videocamera.svg?dataurl"
|
import MicrophoneOff from "@assets/icons/microphone-off.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"
|
||||||
@@ -28,20 +23,14 @@
|
|||||||
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"
|
||||||
|
|
||||||
@@ -77,121 +66,29 @@
|
|||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToRoom = () => {
|
const openAudioSettings = () => {
|
||||||
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 items-start justify-between gap-2">
|
<div class="flex flex-col gap-0.5">
|
||||||
<button
|
{#if $voiceState === VoiceState.Joining}
|
||||||
type="button"
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
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"
|
{:else if $voiceState === VoiceState.Connected}
|
||||||
onclick={goToRoom}
|
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||||
aria-label="Open room {roomName}">
|
{:else}
|
||||||
<div class="flex flex-col gap-0.5">
|
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
||||||
{#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 flex-wrap items-center gap-2">
|
<div class="flex items-center gap-1">
|
||||||
{#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
|
||||||
@@ -203,35 +100,16 @@
|
|||||||
{: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={cx(
|
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||||
mediaToggleClass,
|
? 'btn-error'
|
||||||
"overflow-visible",
|
: 'btn-ghost'}"
|
||||||
!$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}>
|
||||||
<span class="relative inline-flex items-center justify-center overflow-visible">
|
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
|
||||||
<Icon icon={Microphone} size={4} />
|
|
||||||
{@render mutedSlash($currentVoiceSession.muted)}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.cameraOn ? "Turn off camera" : "Turn on camera"}
|
data-tip="Audio settings"
|
||||||
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={openCallSettings}>
|
onclick={openAudioSettings}>
|
||||||
<Icon icon={Settings} size={4} />
|
<Icon icon={Settings} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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,
|
||||||
@@ -351,6 +352,22 @@ 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,6 +3,7 @@ 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,
|
||||||
@@ -325,7 +326,7 @@ if (ENABLE_ZAPS) {
|
|||||||
REACTION_KINDS.push(ZAP_RESPONSE)
|
REACTION_KINDS.push(ZAP_RESPONSE)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED]
|
export const CONTENT_KINDS = [ZAP_GOAL, EVENT_TIME, THREAD, CLASSIFIED, Poll]
|
||||||
|
|
||||||
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
export const MESSAGE_KINDS = [...CONTENT_KINDS, MESSAGE]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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,
|
||||||
@@ -281,6 +282,7 @@ 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},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -300,6 +302,7 @@ 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) {
|
||||||
@@ -311,7 +314,7 @@ const syncSpace = (url: string, rooms: string[]) => {
|
|||||||
listen({
|
listen({
|
||||||
url,
|
url,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
filters: [{kinds: REACTION_KINDS}],
|
filters: [{kinds: REACTION_KINDS}, {kinds: [PollResponse]}],
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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,6 +6,7 @@ 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 {
|
||||||
@@ -90,6 +91,8 @@ 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
|
||||||
|
|
||||||
@@ -146,6 +149,10 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -192,5 +199,7 @@ 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,6 +17,7 @@ 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"],
|
||||||
@@ -35,6 +36,7 @@ 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>
|
||||||
|
|||||||
+2
-162
@@ -4,13 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
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"
|
||||||
@@ -33,8 +32,6 @@ 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
|
||||||
@@ -54,7 +51,6 @@ 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 (
|
||||||
@@ -75,9 +71,6 @@ 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}`})
|
||||||
}
|
}
|
||||||
@@ -87,31 +80,8 @@ 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)
|
||||||
@@ -146,16 +116,6 @@ 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,
|
||||||
@@ -237,10 +197,7 @@ 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 =
|
||||||
@@ -259,16 +216,11 @@ 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}[]) => {
|
||||||
@@ -289,18 +241,6 @@ 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 = () => {
|
||||||
@@ -338,7 +278,6 @@ 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 {
|
||||||
@@ -362,14 +301,7 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
currentVoiceSession.set({
|
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
||||||
url,
|
|
||||||
h,
|
|
||||||
room: liveKitRoom,
|
|
||||||
muted,
|
|
||||||
cameraOn: false,
|
|
||||||
screenShareOn: false,
|
|
||||||
})
|
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -388,27 +320,8 @@ 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())
|
||||||
@@ -439,76 +352,3 @@ 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,7 +14,6 @@
|
|||||||
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="scroll-container bottom-sai top-sai cw fixed mb-14 overflow-auto bg-base-200 md:mb-0 {props.class}">
|
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}">
|
||||||
{@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="cw top-sai fixed z-nav p-2 {props.class}">
|
<div data-component="PageBar" class="relative z-nav p-2 -mb-4 {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,20 +5,12 @@
|
|||||||
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(), contentFrame = "default", ...props}: Props = $props()
|
let {children, element = $bindable(), ...props}: Props = $props()
|
||||||
|
|
||||||
const className = $derived(
|
const className = cx(props.class, "scroll-container z-feature overflow-y-auto overflow-x-hidden")
|
||||||
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,4 +1,5 @@
|
|||||||
<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"
|
||||||
|
|
||||||
@@ -13,32 +14,35 @@
|
|||||||
} = $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>
|
||||||
|
|
||||||
{#if onclick}
|
<div class={wrapperClass} data-tip={title}>
|
||||||
<Button {onclick} class="relative z-nav-item flex h-14 w-14 items-center justify-center p-1">
|
{#if onclick}
|
||||||
<div
|
<Button {onclick} class={innerClass}>
|
||||||
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}
|
||||||
</div>
|
</Button>
|
||||||
</Button>
|
{:else}
|
||||||
{:else}
|
<a {href} class={innerClass}>
|
||||||
<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}
|
||||||
</div>
|
</a>
|
||||||
</a>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cx(
|
class={cx(
|
||||||
"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",
|
"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",
|
||||||
props.class,
|
props.class,
|
||||||
)}>
|
)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -21,22 +21,36 @@
|
|||||||
</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 {children, href = "", notification = false, replaceState = false, ...restProps} = $props()
|
const {
|
||||||
|
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="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
class={wrapperClass}>
|
||||||
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>
|
||||||
@@ -44,11 +58,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button {...restProps} data-tip={title} class={wrapperClass}>
|
||||||
{...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-4 {props.class}">
|
<div class="flex flex-col gap-1 px-2 py-2 {props.class}">
|
||||||
{@render props.children?.()}
|
{@render props.children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page class="cw-full">
|
<Page>
|
||||||
<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-4 -mt-4 h-12 w-12" onclick={startEdit}>
|
<Button class="center btn btn-circle btn-neutral -mr-2 -mt-2 h-12 w-12" onclick={startEdit}>
|
||||||
<Icon icon={PenNewSquare} />
|
<Icon icon={PenNewSquare} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,8 +180,8 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page class="cw-full">
|
<Page>
|
||||||
<PageBar class="cw-full">
|
<PageBar>
|
||||||
{#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="cw-full flex flex-col gap-2 p-2 pt-4">
|
<PageContent class="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-4">
|
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-2">
|
||||||
<PrimaryNavSpaces />
|
<PrimaryNavSpaces />
|
||||||
</div>
|
</div>
|
||||||
<SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
|
<SecondaryNav class="!flex !w-auto flex-grow pb-16">
|
||||||
<SpaceMenu {url} />
|
<SpaceMenu {url} />
|
||||||
</SecondaryNav>
|
</SecondaryNav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
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")}
|
||||||
{@render children?.()}
|
<slot />
|
||||||
{/key}
|
{/key}
|
||||||
|
|||||||
@@ -8,18 +8,11 @@
|
|||||||
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 {
|
import {makeEvent, makeRoomMeta, MESSAGE, ROOM_ADD_MEMBER} from "@welshman/util"
|
||||||
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"
|
||||||
@@ -37,7 +30,6 @@
|
|||||||
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,
|
||||||
@@ -51,15 +43,7 @@
|
|||||||
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 VideoCallContent from "@app/components/VideoCallContent.svelte"
|
import {VoiceState, voiceState} from "@app/voice"
|
||||||
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"
|
||||||
@@ -72,50 +56,6 @@
|
|||||||
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")!))
|
||||||
@@ -285,8 +225,6 @@
|
|||||||
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)
|
||||||
@@ -341,7 +279,7 @@
|
|||||||
showPubkey:
|
showPubkey:
|
||||||
previousPubkey !== event.pubkey ||
|
previousPubkey !== event.pubkey ||
|
||||||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
||||||
[ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER].includes(previousKind!),
|
previousKind === ROOM_ADD_MEMBER,
|
||||||
})
|
})
|
||||||
|
|
||||||
previousDate = date
|
previousDate = date
|
||||||
@@ -366,7 +304,7 @@
|
|||||||
url,
|
url,
|
||||||
at: at || now(),
|
at: at || now(),
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}],
|
filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER], "#h": [h]}],
|
||||||
onBackwardExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loadingBackward = false
|
loadingBackward = false
|
||||||
},
|
},
|
||||||
@@ -397,22 +335,9 @@
|
|||||||
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 () => {
|
return cleanup
|
||||||
cleanup()
|
|
||||||
observer.unobserve(chatCompose!)
|
|
||||||
observer.unobserve(dynamicPadding!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -429,17 +354,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent
|
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
||||||
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">
|
||||||
@@ -483,8 +398,6 @@
|
|||||||
{@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
|
||||||
@@ -506,29 +419,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="h-screen"></div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|
||||||
{#if voiceConnectedHere}
|
<div class="chat__compose flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0">
|
||||||
<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 -->
|
||||||
@@ -576,8 +470,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
||||||
<div
|
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
||||||
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, RELAY_REMOVE_MEMBER} from "@welshman/util"
|
import {makeEvent, MESSAGE, RELAY_ADD_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,8 +163,6 @@
|
|||||||
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)
|
||||||
@@ -219,7 +217,7 @@
|
|||||||
showPubkey:
|
showPubkey:
|
||||||
previousPubkey !== event.pubkey ||
|
previousPubkey !== event.pubkey ||
|
||||||
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
event.created_at - previousCreatedAt > int(3, MINUTE) ||
|
||||||
[RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER].includes(previousKind!),
|
previousKind === RELAY_ADD_MEMBER,
|
||||||
})
|
})
|
||||||
|
|
||||||
previousDate = date
|
previousDate = date
|
||||||
@@ -244,7 +242,7 @@
|
|||||||
url,
|
url,
|
||||||
at: at || now(),
|
at: at || now(),
|
||||||
element: element!,
|
element: element!,
|
||||||
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
|
filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER]}],
|
||||||
onBackwardExhausted: () => {
|
onBackwardExhausted: () => {
|
||||||
loadingBackward = false
|
loadingBackward = false
|
||||||
},
|
},
|
||||||
@@ -275,24 +273,9 @@
|
|||||||
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 () => {
|
return cleanup
|
||||||
cleanup()
|
|
||||||
controller.abort()
|
|
||||||
observer.unobserve(chatCompose!)
|
|
||||||
observer.unobserve(dynamicPadding!)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -306,8 +289,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
|
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4 mb-14 md:mb-0">
|
||||||
<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>
|
||||||
@@ -329,8 +311,6 @@
|
|||||||
{@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
|
||||||
@@ -351,9 +331,10 @@
|
|||||||
<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" bind:this={chatCompose}>
|
<div class="chat__compose bg-base-200">
|
||||||
<div>
|
<div>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<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,8 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {tick, onMount} from "svelte"
|
||||||
import {derived} from "svelte/store"
|
import {derived} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {groupBy, ago, MONTH, first, sortBy, uniqBy} from "@welshman/lib"
|
import {debounce} from "throttle-debounce"
|
||||||
|
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,
|
||||||
@@ -13,12 +28,17 @@
|
|||||||
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"
|
||||||
@@ -26,8 +46,11 @@
|
|||||||
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)
|
||||||
@@ -88,9 +111,93 @@
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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!,
|
||||||
@@ -108,28 +215,101 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<div bind:this={element}>
|
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
|
||||||
<PageContent class="flex flex-col gap-2 p-2 pt-4">
|
{#if $recentActivity.length === 0}
|
||||||
{#if $recentActivity.length === 0}
|
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
{:else}
|
||||||
{:else}
|
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
||||||
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
{#if type === "message"}
|
||||||
{#if type === "message"}
|
<RecentConversation {url} {event} {count} />
|
||||||
<RecentConversation {url} {event} {count} />
|
{:else if event.kind === THREAD}
|
||||||
{:else if event.kind === THREAD}
|
<ThreadItem {url} {event} />
|
||||||
<ThreadItem {url} {event} />
|
{:else if event.kind === CLASSIFIED}
|
||||||
{:else if event.kind === CLASSIFIED}
|
<ClassifiedItem {url} {event} />
|
||||||
<ClassifiedItem {url} {event} />
|
{:else if event.kind === ZAP_GOAL}
|
||||||
{:else if event.kind === ZAP_GOAL}
|
<GoalItem {url} {event} />
|
||||||
<GoalItem {url} {event} />
|
{:else if event.kind === EVENT_TIME}
|
||||||
{:else if event.kind === EVENT_TIME}
|
<CalendarEventItem {url} {event} />
|
||||||
<CalendarEventItem {url} {event} />
|
{:else if event.kind === Poll}
|
||||||
{:else}
|
<PollItem {url} {event} />
|
||||||
<NoteItem {url} {event} />
|
{:else}
|
||||||
{/if}
|
<NoteItem {url} {event} />
|
||||||
{/each}
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
</PageContent>
|
{/if}
|
||||||
</div>
|
</PageContent>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page class="cw-full">
|
<Page>
|
||||||
<PageContent class="cw-full flex flex-col items-center gap-2 p-2 pt-4">
|
<PageContent class="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,
|
||||||
"modal-feature": 8,
|
tooltip: 8,
|
||||||
toast: 9,
|
toast: 9,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user