chore: improve mobile UI, zap modals, and interaction polish (#316)
Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
parent
e1c439fb6b
commit
caa16b6770
30 changed files with 891 additions and 187 deletions
|
|
@ -185,7 +185,7 @@
|
|||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
Goal Amount (sats)*
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
{/snippet}
|
||||
</FieldInline>
|
||||
<input
|
||||
class="range -mt-2 w-full"
|
||||
class="range w-full"
|
||||
type="range"
|
||||
min="1000"
|
||||
max="100000"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
import Button from "@lib/components/Button.svelte"
|
||||
import Reaction from "@app/components/Reaction.svelte"
|
||||
import ReportDetails from "@app/components/ReportDetails.svelte"
|
||||
import ProfileList from "@app/components/ProfileList.svelte"
|
||||
import ZapModal from "@app/components/Zap.svelte"
|
||||
import {REACTION_KINDS} from "@app/content"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
}),
|
||||
)
|
||||
|
||||
const onReactionClick = (events: TrustedEvent[]) => {
|
||||
const toggleReaction = (events: TrustedEvent[]) => {
|
||||
const reaction = events.find(e => e.pubkey === $pubkey)
|
||||
|
||||
if (reaction) {
|
||||
|
|
@ -95,6 +97,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
const showReactors = (pubkeys: string[], title: string, subtitle: string) =>
|
||||
pushModal(ProfileList, {title, subtitle, pubkeys, url})
|
||||
|
||||
const onReactionClick = (events: TrustedEvent[], pubkeys: string[], info: string) => {
|
||||
if (isMobile) {
|
||||
showReactors(pubkeys, info.replace(" reacted", ""), "Reacted to this message")
|
||||
return
|
||||
}
|
||||
|
||||
toggleReaction(events)
|
||||
}
|
||||
|
||||
const onZapClick = (pubkeys: string[], info: string) => {
|
||||
if (isMobile) {
|
||||
showReactors(pubkeys, info.replace(" zapped", ""), "Zapped this message")
|
||||
} else {
|
||||
pushModal(ZapModal, {url, pubkey: event.pubkey, eventId: event.id})
|
||||
}
|
||||
}
|
||||
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
|
||||
const onReportClick = () => pushModal(ReportDetails, {url, event})
|
||||
|
|
@ -158,6 +180,7 @@
|
|||
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
|
||||
{@const tooltip = `${info} zapped`}
|
||||
{@const onZapClickHandler = () => onZapClick(pubkeys, tooltip)}
|
||||
<Button
|
||||
data-tip={tooltip}
|
||||
class={cx(
|
||||
|
|
@ -168,7 +191,8 @@
|
|||
"button-primary": isOwn,
|
||||
"button-neutral": !isOwn,
|
||||
},
|
||||
)}>
|
||||
)}
|
||||
onclick={stopPropagation(onZapClickHandler)}>
|
||||
<Reaction event={zaps[0].request} />
|
||||
<span>{amount}</span>
|
||||
</Button>
|
||||
|
|
@ -178,7 +202,7 @@
|
|||
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
|
||||
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
|
||||
{@const tooltip = `${info} reacted`}
|
||||
{@const onClick = () => onReactionClick(events)}
|
||||
{@const onClick = () => onReactionClick(events, pubkeys, info)}
|
||||
<Button
|
||||
data-tip={tooltip}
|
||||
class={cx(reactionClass, "button button-xs flex-inline gap-1 rounded-full font-normal", {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
<script lang="ts">
|
||||
import {roleColor, roleColorSoft, type SpaceRole} from "@app/members"
|
||||
import cx from "classnames"
|
||||
import {roleColor, type SpaceRole} from "@app/members"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
role: SpaceRole
|
||||
size?: "sm" | "md"
|
||||
}
|
||||
|
||||
const {role}: Props = $props()
|
||||
const {role, size = "sm"}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="badge badge-sm w-fit min-w-0"
|
||||
style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor(
|
||||
role.color,
|
||||
)}; color: {roleColor(role.color)};">
|
||||
<strong>{role.label || "Untitled Role"}</strong>
|
||||
</div>
|
||||
<span
|
||||
class={cx("role-badge", size === "md" && "role-badge--md")}
|
||||
style="--role-color: {roleColor(role.color)}">
|
||||
<span class="role-badge__dot" aria-hidden="true"></span>
|
||||
{role.label || "Untitled Role"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2 justify-between">
|
||||
<RoleBadge {role} />
|
||||
<RoleBadge {role} size="md" />
|
||||
{#if role.description}
|
||||
<p class="text-sm opacity-70">{role.description}</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -115,11 +115,12 @@
|
|||
</div>
|
||||
{#if $members !== undefined && $members.length > 0}
|
||||
<div class="card card-sm flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>Members:</span>
|
||||
<ProfileCircles pubkeys={$members} />
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="shrink-0">Members:</span>
|
||||
<ProfileCircles pubkeys={$members} class="min-w-0 overflow-hidden" />
|
||||
</div>
|
||||
<Button class="button button-neutral button-sm" onclick={showMembers}>View All</Button>
|
||||
<Button class="button button-neutral button-sm shrink-0" onclick={showMembers}
|
||||
>View All</Button>
|
||||
</div>
|
||||
{:else if $members === undefined}
|
||||
<div class="card card-sm bg-surface flex items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@
|
|||
</div>
|
||||
{#if !isMobile}
|
||||
<button
|
||||
class="join absolute right-2 top-0.5 opacity-0 transition-all"
|
||||
class="room__item-actions join absolute right-2 top-0.5 opacity-0 transition-all"
|
||||
class:group-hover:opacity-100={!isMobile}>
|
||||
{#if ENABLE_ZAPS}
|
||||
<RoomItemZapButton {url} {event} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ManagementMethod, getAddress} from "@welshman/util"
|
||||
import {ManagementMethod, getAddress, getTagValue} from "@welshman/util"
|
||||
import {pubkey, manageRelay, repository} from "@welshman/app"
|
||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
|
||||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Confirm from "@lib/components/Confirm.svelte"
|
||||
|
|
@ -13,9 +14,11 @@
|
|||
import Report from "@app/components/Report.svelte"
|
||||
import PinboardSelect from "@app/components/PinboardSelect.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {ROOM} from "@app/groups"
|
||||
import {deriveUserIsRoomAdmin, deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {deriveRoomPinIds, pinRoomMessage, unpinRoomMessage} from "@app/pins"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -25,7 +28,11 @@
|
|||
|
||||
const {url, event, onClick}: Props = $props()
|
||||
|
||||
const h = getTagValue(ROOM, event.tags) ?? ""
|
||||
const pinIds = deriveRoomPinIds(url, h)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
|
||||
const isPinned = $derived($pinIds.includes(event.id))
|
||||
|
||||
const addToLibrary = () => {
|
||||
onClick()
|
||||
|
|
@ -66,6 +73,22 @@
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
const togglePin = async () => {
|
||||
onClick()
|
||||
|
||||
if (!h) return
|
||||
|
||||
const error = isPinned
|
||||
? await unpinRoomMessage(url, h, event.id, $pinIds)
|
||||
: await pinRoomMessage(url, h, event.id, $pinIds)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="menu bg-surface whitespace-nowrap rounded-2xl p-2">
|
||||
|
|
@ -81,6 +104,14 @@
|
|||
Add to Library
|
||||
</Button>
|
||||
</li>
|
||||
{#if h && $userIsRoomAdmin}
|
||||
<li>
|
||||
<Button onclick={togglePin}>
|
||||
<Icon size={4} icon={Pin} />
|
||||
{isPinned ? "Unpin Message" : "Pin Message"}
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if event.pubkey === $pubkey}
|
||||
<li>
|
||||
<Button onclick={showDelete} class="text-error">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getTagValue} from "@welshman/util"
|
||||
import {pubkey} from "@welshman/app"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||
|
|
@ -8,6 +9,7 @@
|
|||
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -17,11 +19,15 @@
|
|||
import ZapButton from "@app/components/ZapButton.svelte"
|
||||
import EventInfo from "@app/components/EventInfo.svelte"
|
||||
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
|
||||
import {ROOM} from "@app/groups"
|
||||
import {deriveUserIsRoomAdmin} from "@app/members"
|
||||
import {deriveRoomPinIds, pinRoomMessage, unpinRoomMessage} from "@app/pins"
|
||||
import {ENABLE_ZAPS} from "@app/env"
|
||||
import {publishReaction} from "@app/reactions"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
import {getRoomItemPath} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
|
|
@ -31,7 +37,11 @@
|
|||
|
||||
const {url, event, reply}: Props = $props()
|
||||
|
||||
const h = getTagValue(ROOM, event.tags) ?? ""
|
||||
const path = getRoomItemPath(url, event)
|
||||
const pinIds = deriveRoomPinIds(url, h)
|
||||
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
|
||||
const isPinned = $derived($pinIds.includes(event.id))
|
||||
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
|
||||
|
|
@ -55,6 +65,22 @@
|
|||
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
|
||||
|
||||
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
|
||||
|
||||
const togglePin = async () => {
|
||||
if (!h) return
|
||||
|
||||
history.back()
|
||||
|
||||
const error = isPinned
|
||||
? await unpinRoomMessage(url, h, event.id, $pinIds)
|
||||
: await pinRoomMessage(url, h, event.id, $pinIds)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
|
|
@ -76,6 +102,12 @@
|
|||
View Details
|
||||
</Link>
|
||||
{/if}
|
||||
{#if h && $userIsRoomAdmin}
|
||||
<Button class="button button-neutral w-full" onclick={togglePin}>
|
||||
<Icon size={4} icon={Pin} />
|
||||
{isPinned ? "Unpin Message" : "Pin Message"}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if ENABLE_ZAPS}
|
||||
<ZapButton replaceState {url} {event} class="button button-neutral w-full">
|
||||
<Icon size={4} icon={Bolt} />
|
||||
|
|
|
|||
192
src/app/components/RoomPinnedMessages.svelte
Normal file
192
src/app/components/RoomPinnedMessages.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<script lang="ts">
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import {fly, slide} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import RoomPinnedMessagesAll from "@app/components/RoomPinnedMessagesAll.svelte"
|
||||
import {
|
||||
deriveRoomPinIds,
|
||||
deriveRoomPinnedEvents,
|
||||
loadRoomPinList,
|
||||
loadRoomPinnedMessages,
|
||||
} from "@app/pins"
|
||||
import {goToEvent} from "@app/routes"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const pinIds = deriveRoomPinIds(url, h)
|
||||
const pinnedEvents = deriveRoomPinnedEvents(url, pinIds)
|
||||
|
||||
let currentIndex = $state(0)
|
||||
let expanded = $state(false)
|
||||
let root: HTMLElement | undefined = $state()
|
||||
|
||||
const total = $derived($pinnedEvents.length)
|
||||
const currentEvent = $derived($pinnedEvents[currentIndex])
|
||||
const counter = $derived(total > 0 ? `${currentIndex + 1}/${total}` : "")
|
||||
|
||||
const preview = (event: TrustedEvent) => event.content.replace(/\s+/g, " ").trim()
|
||||
|
||||
const formatNpub = (pubkey: string) => {
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
|
||||
return `${npub.slice(0, 10)}...${npub.slice(-5)}`
|
||||
}
|
||||
|
||||
const reset = (_url: string, _h: string) => {
|
||||
currentIndex = 0
|
||||
expanded = false
|
||||
}
|
||||
|
||||
const expand = () => {
|
||||
expanded = true
|
||||
}
|
||||
|
||||
const collapse = () => {
|
||||
expanded = false
|
||||
}
|
||||
|
||||
const showAll = () => {
|
||||
pushModal(RoomPinnedMessagesAll, {url, h})
|
||||
}
|
||||
|
||||
const openCounter = () => {
|
||||
showAll()
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (total === 0) return
|
||||
|
||||
currentIndex = (currentIndex - 1 + total) % total
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (total === 0) return
|
||||
|
||||
currentIndex = (currentIndex + 1) % total
|
||||
}
|
||||
|
||||
const jumpToMessage = () => {
|
||||
if (!currentEvent) return
|
||||
|
||||
collapse()
|
||||
goToEvent(currentEvent)
|
||||
}
|
||||
|
||||
const onDocumentPointerDown = (event: PointerEvent) => {
|
||||
if (!expanded || !root) return
|
||||
|
||||
if (!root.contains(event.target as Node)) {
|
||||
collapse()
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
reset(url, h)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const ids = $pinIds
|
||||
const controller = new AbortController()
|
||||
|
||||
loadRoomPinList(url, h, controller.signal)
|
||||
loadRoomPinnedMessages(url, ids, controller.signal)
|
||||
|
||||
return () => controller.abort()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (currentIndex >= $pinnedEvents.length) {
|
||||
currentIndex = 0
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return
|
||||
|
||||
document.addEventListener("pointerdown", onDocumentPointerDown)
|
||||
|
||||
return () => document.removeEventListener("pointerdown", onDocumentPointerDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if total > 0 && currentEvent}
|
||||
<div class="room-pins-anchor" bind:this={root} in:fly={{y: -16, duration: 250}}>
|
||||
<div class="room-pins" class:room-pins--expanded={expanded}>
|
||||
<div class="room-pins__header">
|
||||
{#if expanded}
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3">
|
||||
<ProfileCircle pubkey={currentEvent.pubkey} {url} size={6} />
|
||||
<div class="min-w-0">
|
||||
<p class="room-pins__label">Pinned message</p>
|
||||
<p class="text-primary-content/70 truncate text-xs">
|
||||
{formatNpub(currentEvent.pubkey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="room-pins__expand" onclick={expand}>
|
||||
<ProfileCircle pubkey={currentEvent.pubkey} {url} size={5} />
|
||||
<p class="room-pins__preview">{preview(currentEvent) || "Pinned message"}</p>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="room-pins__counter">
|
||||
<Button
|
||||
class="button button-ghost button-sm gap-1 text-primary-content"
|
||||
aria-label="All pinned messages"
|
||||
onclick={showAll}>
|
||||
<Icon icon={Pin} size={4} />
|
||||
<span class="text-xs">{counter}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if expanded}
|
||||
<div class="room-pins__details" transition:slide={{duration: 200}}>
|
||||
<div class="room-pins__message">
|
||||
{#key currentEvent.id}
|
||||
<RoomItemContent {url} event={currentEvent} />
|
||||
{/key}
|
||||
</div>
|
||||
<div class="room-pins__footer">
|
||||
<div class="room-pins__nav">
|
||||
{#if total > 1}
|
||||
<Button
|
||||
class="button button-ghost button-sm button-square text-primary-content"
|
||||
aria-label="Previous pinned message"
|
||||
onclick={prev}>
|
||||
<Icon icon={AltArrowLeft} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
<button type="button" class="px-1 text-sm" onclick={openCounter}>{counter}</button>
|
||||
{#if total > 1}
|
||||
<Button
|
||||
class="button button-ghost button-sm button-square text-primary-content"
|
||||
aria-label="Next pinned message"
|
||||
onclick={next}>
|
||||
<Icon icon={AltArrowRight} size={4} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
class="button button-sm rounded-lg border-0 bg-primary-content font-medium text-primary"
|
||||
onclick={jumpToMessage}>
|
||||
Jump to message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
115
src/app/components/RoomPinnedMessagesAll.svelte
Normal file
115
src/app/components/RoomPinnedMessagesAll.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.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 ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import RoomItemContent from "@app/components/RoomItemContent.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import {deriveUserIsRoomAdmin} from "@app/members"
|
||||
import {
|
||||
deriveRoomPinIds,
|
||||
deriveRoomPinnedEvents,
|
||||
loadRoomPinList,
|
||||
loadRoomPinnedMessages,
|
||||
unpinRoomMessage,
|
||||
} from "@app/pins"
|
||||
import {goToEvent} from "@app/routes"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
h: string
|
||||
}
|
||||
|
||||
const {url, h}: Props = $props()
|
||||
|
||||
const pinIds = deriveRoomPinIds(url, h)
|
||||
const pinnedEvents = deriveRoomPinnedEvents(url, pinIds)
|
||||
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
const jumpToMessage = (event: TrustedEvent) => {
|
||||
back()
|
||||
goToEvent(event)
|
||||
}
|
||||
|
||||
const unpin = async (event: TrustedEvent) => {
|
||||
const error = await unpinRoomMessage(url, h, event.id, $pinIds)
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: "Message unpinned"})
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const ids = $pinIds
|
||||
const controller = new AbortController()
|
||||
|
||||
loadRoomPinList(url, h, controller.signal)
|
||||
loadRoomPinnedMessages(url, ids, controller.signal)
|
||||
|
||||
return () => controller.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal>
|
||||
<ModalBody>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Pinned Messages</ModalTitle>
|
||||
<ModalSubtitle>
|
||||
in <RoomName {url} {h} class="text-primary" />
|
||||
</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $pinnedEvents as event (event.id)}
|
||||
{@const onUnpin = () => unpin(event)}
|
||||
{@const onJump = () => jumpToMessage(event)}
|
||||
<div class="card card-sm flex flex-col gap-3 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={6} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-content-muted text-xs font-bold tracking-wide uppercase">
|
||||
Pinned message
|
||||
</p>
|
||||
<p class="truncate text-sm">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</p>
|
||||
</div>
|
||||
<Icon icon={Pin} size={4} class="text-primary shrink-0 opacity-70" />
|
||||
</div>
|
||||
<RoomItemContent {url} {event} />
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
{#if $userIsAdmin}
|
||||
<Button class="button button-link button-sm" onclick={onUnpin}>Unpin</Button>
|
||||
{/if}
|
||||
<Button class="button button-primary button-sm" onclick={onJump}>
|
||||
Jump to message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card card-sm p-4">
|
||||
<p class="text-content-muted text-center text-sm">No pinned messages.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
<Icon icon={AltArrowLeft} />
|
||||
Go back
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Profile {pubkey} {url} inert />
|
||||
{#if roles.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{#each roles as role (role.id)}
|
||||
<RoleBadge {role} />
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {matchMd} from "@lib/theme"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
|
||||
|
||||
|
|
@ -12,9 +13,9 @@
|
|||
const {url, h, threads}: Props = $props()
|
||||
</script>
|
||||
|
||||
<section class="card card-flat overflow-hidden pb-4 p-0">
|
||||
<section class="card card-flat p-0">
|
||||
<header
|
||||
class="flex gap-2 items-center justify-between px-4 py-3 border-b border-line border-solid">
|
||||
class="flex items-center justify-between gap-2 border-b border-solid border-line px-4 py-3">
|
||||
<h2 class="text-lg">
|
||||
{#if h}
|
||||
<RoomNameWithImage {url} {h} />
|
||||
|
|
@ -27,20 +28,30 @@
|
|||
{threads.length === 1 ? "Topic" : "Topics"}
|
||||
</span>
|
||||
</header>
|
||||
<table class="w-full border-collapse">
|
||||
<thead
|
||||
class="hidden text-xs font-bold uppercase tracking-wide text-content-muted sm:table-header-group border-b border-line border-solid bg-surface-less">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Topic</th>
|
||||
<th class="w-32 px-4 py-3 text-left">Author</th>
|
||||
<th class="w-20 px-4 py-3 text-center">Replies</th>
|
||||
<th class="w-32 px-4 py-3 text-right">Last post</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if $matchMd}
|
||||
<div class="scroll-container overflow-x-auto pb-4">
|
||||
<table class="w-full min-w-[640px] border-collapse">
|
||||
<thead
|
||||
class="border-b border-solid border-line bg-surface-less text-xs font-bold uppercase tracking-wide text-content-muted">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Topic</th>
|
||||
<th class="w-32 px-4 py-3 text-left">Author</th>
|
||||
<th class="w-20 px-4 py-3 text-center">Replies</th>
|
||||
<th class="w-32 px-4 py-3 text-right">Last post</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each threads as event (event.id)}
|
||||
<ThreadBoardItem {url} {event} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pb-4">
|
||||
{#each threads as event (event.id)}
|
||||
<ThreadBoardItem {url} {event} />
|
||||
<ThreadBoardItem mobile {url} {event} />
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@
|
|||
type Props = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
mobile?: boolean
|
||||
}
|
||||
|
||||
const {url, event}: Props = $props()
|
||||
const {url, event, mobile = false}: Props = $props()
|
||||
|
||||
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||
const replies = deriveEventsForUrl(url, filters)
|
||||
|
|
@ -21,25 +22,47 @@
|
|||
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||
const title = getTagValue("title", event.tags)
|
||||
const path = makeThreadPath(url, event.id)
|
||||
|
||||
const goToThread = () => goto(path)
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class="cursor-pointer transition-colors hover:bg-surface-less text-sm border-b border-solid border-line"
|
||||
onclick={goToThread}>
|
||||
<td class="px-4 py-3 align-top truncate">
|
||||
{title || "Untitled thread"}
|
||||
</td>
|
||||
<td class="px-4 py-3 align-middle flex items-center gap-2">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={5} />
|
||||
<span class="truncate min-w-0">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 align-middle text-right">
|
||||
{replyCount}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2 align-middle text-right">
|
||||
{formatTimestamp(lastActive)}
|
||||
</td>
|
||||
</tr>
|
||||
{#if mobile}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors"
|
||||
onclick={goToThread}>
|
||||
<p class="truncate font-medium">{title || "Untitled thread"}</p>
|
||||
<div class="text-content-muted flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={4} />
|
||||
<span class="truncate">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</span>
|
||||
<span>{replyCount} {replyCount === 1 ? "reply" : "replies"}</span>
|
||||
<span>{formatTimestamp(lastActive)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<tr
|
||||
class="hover:bg-surface-less cursor-pointer border-b border-solid border-line text-sm transition-colors"
|
||||
onclick={goToThread}>
|
||||
<td class="max-w-0 truncate px-4 py-3 align-top">
|
||||
{title || "Untitled thread"}
|
||||
</td>
|
||||
<td class="w-32 px-4 py-3 align-middle">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProfileCircle pubkey={event.pubkey} {url} size={5} />
|
||||
<span class="min-w-0 truncate">
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-20 px-4 py-3 text-center align-middle">
|
||||
{replyCount}
|
||||
</td>
|
||||
<td class="w-32 whitespace-nowrap px-4 py-3 text-right align-middle">
|
||||
{formatTimestamp(lastActive)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
<span class="truncate min-w-0 text-xs opacity-75">{displayHandle($handle)}</span>
|
||||
{/if}
|
||||
{#if isOp}
|
||||
<Badge variant="primary">OP</Badge>
|
||||
<Badge variant="primary" class="w-fit self-start md:self-center">OP</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {first} from "@welshman/lib"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import {signer, deriveZapperForPubkey} from "@welshman/app"
|
||||
import {load} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
|
|
@ -11,16 +9,15 @@
|
|||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.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 EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ZapForm from "@app/components/ZapForm.svelte"
|
||||
import {payInvoice} from "@app/lightning"
|
||||
import {zapAmounts} from "@app/settings"
|
||||
import {pushToast} from "@app/toast"
|
||||
|
|
@ -37,14 +34,6 @@
|
|||
|
||||
const back = () => history.back()
|
||||
|
||||
const selectAmount = (preset: number) => {
|
||||
amount = preset
|
||||
}
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) => {
|
||||
content = emoji.unicode
|
||||
}
|
||||
|
||||
const sendZap = async () => {
|
||||
loading = true
|
||||
|
||||
|
|
@ -94,42 +83,7 @@
|
|||
<ModalTitle>Send a Zap</ModalTitle>
|
||||
<ModalSubtitle>To <ProfileLink {pubkey} class="text-primary!" /></ModalSubtitle>
|
||||
</ModalHeader>
|
||||
<FieldInline class="grid-cols-3!">
|
||||
{#snippet label()}
|
||||
Emoji Reaction
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex grow items-center justify-end gap-4">
|
||||
<EmojiButton {onEmoji}>
|
||||
{content}
|
||||
</EmojiButton>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline class="grid-cols-3!">
|
||||
{#snippet label()}
|
||||
Amount
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input input-group flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={amount} type="number" class="w-24" />
|
||||
</label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{#each $zapAmounts as preset}
|
||||
<Button
|
||||
class={cx(
|
||||
`button button-${preset === amount ? "primary" : "neutral"} button-sm button-pill`,
|
||||
)}
|
||||
onclick={() => selectAmount(preset)}>
|
||||
{preset}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<ZapForm bind:amount bind:content />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button class="button button-link" onclick={back}>
|
||||
|
|
|
|||
65
src/app/components/ZapForm.svelte
Normal file
65
src/app/components/ZapForm.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {Snippet} from "svelte"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import {zapAmounts} from "@app/settings"
|
||||
|
||||
export type Values = {
|
||||
amount: number
|
||||
content: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
amount: number
|
||||
content: string
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
let {amount = $bindable(), content = $bindable(), children}: Props = $props()
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) => {
|
||||
content = emoji.unicode
|
||||
}
|
||||
|
||||
const selectAmount = (preset: number) => {
|
||||
amount = preset
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Field>
|
||||
{#snippet label()}
|
||||
<p>Amount (sats)</p>
|
||||
{/snippet}
|
||||
{#snippet secondary()}
|
||||
<EmojiButton {onEmoji} class="button button-neutral button-sm h-10 w-10 text-xl">
|
||||
{content}
|
||||
</EmojiButton>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={amount} type="number" class="min-w-0 grow" />
|
||||
<p class="shrink-0 opacity-50">sats</p>
|
||||
</label>
|
||||
{/snippet}
|
||||
</Field>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{#each $zapAmounts as preset}
|
||||
<Button
|
||||
class={cx("button button-sm", {
|
||||
"button-primary": preset === amount,
|
||||
"button-neutral": preset !== amount,
|
||||
})}
|
||||
onclick={() => selectAmount(preset)}>
|
||||
{preset}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import {onDestroy} from "svelte"
|
||||
import {first} from "@welshman/lib"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import {signer, deriveZapperForPubkey} from "@welshman/app"
|
||||
import {request} from "@welshman/net"
|
||||
import {Router} from "@welshman/router"
|
||||
|
|
@ -13,18 +11,17 @@
|
|||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import Modal from "@lib/components/Modal.svelte"
|
||||
import ModalBody from "@lib/components/ModalBody.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 EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import QRCode from "@app/components/QRCode.svelte"
|
||||
import WalletConnect from "@app/components/WalletConnect.svelte"
|
||||
import ZapForm from "@app/components/ZapForm.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {zapAmounts} from "@app/settings"
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
|
|
@ -41,14 +38,6 @@
|
|||
|
||||
const back = () => history.back()
|
||||
|
||||
const selectAmount = (preset: number) => {
|
||||
amount = preset
|
||||
}
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) => {
|
||||
content = emoji.unicode
|
||||
}
|
||||
|
||||
const createInvoice = async () => {
|
||||
loading = true
|
||||
|
||||
|
|
@ -125,61 +114,35 @@
|
|||
</ModalHeader>
|
||||
|
||||
{#if invoice}
|
||||
<div class="flex flex-col items-center gap-6 pt-4">
|
||||
<QRCode code={invoice} class="w-full max-w-64" />
|
||||
<p class="text-center text-sm opacity-75">
|
||||
Scan with your wallet, or copy the invoice manually.
|
||||
</p>
|
||||
</div>
|
||||
<label class="input flex w-full items-center justify-between gap-2">
|
||||
<input readonly class="truncate min-w-0 grow" value={invoice} />
|
||||
<Button class="flex items-center" onclick={copyInvoice}>
|
||||
<Icon icon={Copy} />
|
||||
</Button>
|
||||
</label>
|
||||
{:else}
|
||||
<FieldInline class="grid-cols-3!">
|
||||
{#snippet label()}
|
||||
Emoji Reaction
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex grow items-center justify-end gap-4">
|
||||
<EmojiButton {onEmoji} class="button button-neutral">
|
||||
{content}
|
||||
</EmojiButton>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline class="grid-cols-3!">
|
||||
{#snippet label()}
|
||||
Amount
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<div class="flex grow justify-end">
|
||||
<label class="input flex items-center gap-2">
|
||||
<Icon icon={Bolt} />
|
||||
<input bind:value={amount} type="number" class="w-24" />
|
||||
</label>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{#each $zapAmounts as preset}
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<QRCode code={invoice} class="w-full max-w-56" />
|
||||
<p class="text-content-muted text-center text-sm">
|
||||
Scan with your lightning wallet, or copy the invoice below.
|
||||
</p>
|
||||
</div>
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<input readonly class="min-w-0 grow truncate" value={invoice} />
|
||||
<Button
|
||||
class={cx(
|
||||
`button button-${preset === amount ? "primary" : "neutral"} button-sm button-pill`,
|
||||
)}
|
||||
onclick={() => selectAmount(preset)}>
|
||||
{preset}
|
||||
class="button button-neutral button-sm button-square shrink-0"
|
||||
onclick={copyInvoice}>
|
||||
<Icon icon={Copy} size={4} />
|
||||
</Button>
|
||||
{/each}
|
||||
</label>
|
||||
</div>
|
||||
<p class="card card-sm text-center flex justify-between items-center">
|
||||
Want to zap directly?
|
||||
<Button class="button button-neutral button-sm" onclick={connectWallet}>
|
||||
Connect a lightning wallet
|
||||
</Button>
|
||||
</p>
|
||||
{:else}
|
||||
<ZapForm bind:amount bind:content>
|
||||
{#snippet children()}
|
||||
<div class="card card-sm card-flat flex flex-col items-center gap-3 p-4 text-center">
|
||||
<p class="text-content-muted text-sm">
|
||||
Connect a wallet to pay instantly without scanning a QR code.
|
||||
</p>
|
||||
<Button class="button button-neutral" onclick={connectWallet}>
|
||||
Connect a lightning wallet
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ZapForm>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
|
|
|||
99
src/app/pins.ts
Normal file
99
src/app/pins.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import {derived} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {makeEvent, getTagValues} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {publishThunk, waitForThunkError} from "@welshman/app"
|
||||
import {load} from "@welshman/net"
|
||||
import {deriveEventsForUrl, deriveLatestEventForUrl} from "@app/repository"
|
||||
import {PROTECTED} from "@app/groups"
|
||||
import {canEnforceNip70} from "@app/relays"
|
||||
|
||||
// NIP-29 proposed pin list kinds (see https://github.com/nostr-protocol/nips/pull/2379)
|
||||
export const ROOM_PINS = 39005
|
||||
export const ROOM_UPDATE_PIN_LIST = 9010
|
||||
|
||||
const pinListFilters = (h: string) => [{kinds: [ROOM_PINS], "#d": [h]}]
|
||||
|
||||
export const deriveRoomPinIds = (url: string, h: string) =>
|
||||
derived(deriveLatestEventForUrl(url, pinListFilters(h)), event =>
|
||||
getTagValues("e", event?.tags ?? []),
|
||||
)
|
||||
|
||||
export const deriveRoomPinnedEvents = (
|
||||
url: string,
|
||||
pinIds: Readable<string[]>,
|
||||
): Readable<TrustedEvent[]> =>
|
||||
derived(pinIds, ($pinIds, set) => {
|
||||
if ($pinIds.length === 0) {
|
||||
set([] as TrustedEvent[])
|
||||
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const events = deriveEventsForUrl(url, [{ids: $pinIds}])
|
||||
|
||||
const unsub = events.subscribe($events => {
|
||||
const byId = new Map($events.map(event => [event.id, event]))
|
||||
|
||||
set(
|
||||
$pinIds.flatMap(id => {
|
||||
const event = byId.get(id)
|
||||
|
||||
return event ? [event] : []
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return () => unsub()
|
||||
})
|
||||
|
||||
export const loadRoomPinList = (url: string, h: string, signal?: AbortSignal) =>
|
||||
load({relays: [url], signal, filters: pinListFilters(h)})
|
||||
|
||||
export const loadRoomPinnedMessages = (url: string, ids: string[], signal?: AbortSignal) => {
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return load({relays: [url], signal, filters: [{ids}]})
|
||||
}
|
||||
|
||||
export const updateRoomPins = async (url: string, h: string, eventIds: string[]) => {
|
||||
const tags: string[][] = [["h", h], ...eventIds.map(id => ["e", id])]
|
||||
|
||||
if (await canEnforceNip70(url)) {
|
||||
tags.push(PROTECTED)
|
||||
}
|
||||
|
||||
const event = makeEvent(ROOM_UPDATE_PIN_LIST, {
|
||||
content: "",
|
||||
tags,
|
||||
})
|
||||
|
||||
return waitForThunkError(publishThunk({relays: [url], event}))
|
||||
}
|
||||
|
||||
export const pinRoomMessage = async (
|
||||
url: string,
|
||||
h: string,
|
||||
eventId: string,
|
||||
currentIds: string[],
|
||||
) => {
|
||||
if (currentIds.includes(eventId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return updateRoomPins(url, h, [...currentIds, eventId])
|
||||
}
|
||||
|
||||
export const unpinRoomMessage = async (
|
||||
url: string,
|
||||
h: string,
|
||||
eventId: string,
|
||||
currentIds: string[],
|
||||
) =>
|
||||
updateRoomPins(
|
||||
url,
|
||||
h,
|
||||
currentIds.filter(id => id !== eventId),
|
||||
)
|
||||
|
|
@ -177,6 +177,34 @@ export const requestRelayClaims = async (urls: string[]) =>
|
|||
|
||||
export const canEnforceNip70 = (url: string) => hasNip70(getRelay(url))
|
||||
|
||||
const authTerminalStatuses = [
|
||||
AuthStatus.None,
|
||||
AuthStatus.Ok,
|
||||
AuthStatus.Forbidden,
|
||||
AuthStatus.DeniedSignature,
|
||||
]
|
||||
|
||||
const waitForAuth = async (socket: ReturnType<ReturnType<typeof Pool.get>["get"]>) => {
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const formatAuthError = (status: AuthStatus, details?: string) => {
|
||||
if (status === AuthStatus.DeniedSignature) {
|
||||
return "Failed to authenticate — check your signer"
|
||||
}
|
||||
|
||||
if (status === AuthStatus.PendingSignature) {
|
||||
return "Failed to authenticate — approve the signing request from your extension or signer"
|
||||
}
|
||||
|
||||
const message = details || last(status.split(":"))
|
||||
|
||||
return `Failed to authenticate (${message})`
|
||||
}
|
||||
|
||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||
const socket = Pool.get().get(url)
|
||||
|
||||
|
|
@ -191,24 +219,18 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
|
|||
return `Failed to connect`
|
||||
}
|
||||
|
||||
// Some relays are extra slow, remove this when welshman is updated
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(3000),
|
||||
condition: () => socket.auth.status === AuthStatus.Requested,
|
||||
})
|
||||
|
||||
await socket.auth.attemptAuth(sign)
|
||||
|
||||
// Some relays are extra slow, remove this when welshman is updated
|
||||
await poll({
|
||||
signal: AbortSignal.timeout(3000),
|
||||
condition: () => socket.auth.status !== AuthStatus.PendingResponse,
|
||||
})
|
||||
for (let i = 0; i < 3 && ![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status); i++) {
|
||||
await socket.auth.retryAuth(sign)
|
||||
await waitForAuth(socket)
|
||||
}
|
||||
|
||||
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
|
||||
const message = socket.auth.details || last(socket.auth.status.split(":"))
|
||||
|
||||
return `Failed to authenticate (${message})`
|
||||
return formatAuthError(socket.auth.status, socket.auth.details)
|
||||
}
|
||||
|
||||
const error = await waitForThunkError(publishJoinRequest({url, claim}))
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import type {Unsubscriber} from "svelte/store"
|
|||
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
|
||||
import {Preferences} from "@capacitor/preferences"
|
||||
import {IDB} from "@lib/indexeddb"
|
||||
import {ROOM_PINS} from "@app/pins"
|
||||
|
||||
export const kv = call(() => {
|
||||
let p = Promise.resolve()
|
||||
|
|
@ -155,6 +156,7 @@ const kinds = {
|
|||
ROOM_ADD_MEMBER,
|
||||
ROOM_REMOVE_MEMBER,
|
||||
ROOM_CREATE_PERMISSION,
|
||||
ROOM_PINS,
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import {FEATURED_CONTENT_D} from "@app/featured"
|
|||
import {BOARD, PIN} from "@app/pinboards"
|
||||
import {hasBlossomSupport} from "@app/uploads"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call"
|
||||
import {ROOM_PINS} from "@app/pins"
|
||||
|
||||
// Utils
|
||||
|
||||
|
|
@ -308,7 +309,17 @@ const syncSpace = (url: string) => {
|
|||
url,
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, ROOM_DELETE, LIVEKIT_PARTICIPANTS, BOARD]},
|
||||
{
|
||||
kinds: [
|
||||
ROOM_META,
|
||||
ROOM_ADMINS,
|
||||
ROOM_MEMBERS,
|
||||
ROOM_DELETE,
|
||||
LIVEKIT_PARTICIPANTS,
|
||||
BOARD,
|
||||
ROOM_PINS,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
popover?.hide()
|
||||
}
|
||||
|
||||
const onMouseMove = throttle(300, ({clientX, clientY}: any) => {
|
||||
const onMouseMove = throttle(300, ({clientX, clientY}: MouseEvent) => {
|
||||
if (popover) {
|
||||
const {x, y, width, height} = popover.popper.getBoundingClientRect()
|
||||
|
||||
|
|
|
|||
|
|
@ -22,4 +22,24 @@
|
|||
border-color: color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
@apply inline-flex min-w-0 items-center gap-1.5 rounded-full border-2 border-solid px-2 py-1 text-xs font-normal;
|
||||
background: color-mix(in srgb, var(--role-color) 12%, transparent);
|
||||
color: var(--role-color);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.role-badge__dot {
|
||||
@apply size-1.5 shrink-0 rounded-full;
|
||||
background-color: var(--role-color);
|
||||
}
|
||||
|
||||
.role-badge--md {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.role-badge--md .role-badge__dot {
|
||||
@apply size-2;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,6 +408,17 @@ emoji-picker {
|
|||
--input-border-color: var(--line);
|
||||
--input-font-color: var(--content);
|
||||
--outline-color: var(--primary);
|
||||
width: min(352px, calc(100vw - 2rem));
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-tippy-root]:has(emoji-picker) {
|
||||
max-width: min(352px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.tippy-box:has(emoji-picker) {
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* content width for fixed elements (sidebar offsets) */
|
||||
|
|
|
|||
|
|
@ -112,4 +112,27 @@
|
|||
.chat-editor .tiptap {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
@apply bg-surface border-line rounded-xl;
|
||||
border-width: var(--border-thin, 2px);
|
||||
border-color: color-mix(in srgb, var(--role-color) 22%, var(--line));
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
box-shadow:
|
||||
4px 4px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
|
||||
.role-badge__dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--role-color) 18%, transparent);
|
||||
}
|
||||
|
||||
.role-badge--md {
|
||||
@apply px-2.5 py-1.5;
|
||||
box-shadow:
|
||||
5px 5px 0 color-mix(in srgb, var(--role-color) 28%, transparent),
|
||||
var(--shadow-inset);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
.primary-nav__nav-item::after {
|
||||
content: "";
|
||||
@apply absolute top-1/2 -left-1 h-0 w-1 -translate-y-1/2 rounded-r max-md:hidden;
|
||||
@apply absolute top-1/2 left-auto h-0 w-1 -translate-y-1/2 rounded-r max-md:hidden;
|
||||
right: calc(100% + 6px);
|
||||
background-color: var(--content);
|
||||
transition: height 150ms ease;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
@layer components {
|
||||
.room {
|
||||
@apply relative;
|
||||
|
||||
.room__content {
|
||||
@apply z-feature flex max-h-screen min-h-0 w-full min-w-0 flex-col flex-col-reverse gap-2 overflow-x-hidden overflow-y-auto;
|
||||
}
|
||||
|
|
@ -8,6 +10,14 @@
|
|||
@apply bg-surface;
|
||||
}
|
||||
|
||||
.room__item-actions {
|
||||
@apply z-popover overflow-visible pb-1;
|
||||
}
|
||||
|
||||
.room__item-actions > .join-item {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.room__compose {
|
||||
@apply z-compose relative mb-14 shrink-0 md:mb-0;
|
||||
}
|
||||
|
|
@ -16,6 +26,58 @@
|
|||
@apply min-w-0;
|
||||
}
|
||||
}
|
||||
|
||||
.room-pins-anchor {
|
||||
@apply z-compose pointer-events-none absolute top-0 right-0 left-0;
|
||||
}
|
||||
|
||||
.room-pins {
|
||||
@apply bg-primary text-primary-content pointer-events-auto mx-3 origin-top rounded-t-none rounded-b-lg shadow-sm;
|
||||
}
|
||||
|
||||
.room-pins__header {
|
||||
@apply flex min-h-10 items-center gap-3 px-4;
|
||||
}
|
||||
|
||||
.room-pins--expanded .room-pins__header {
|
||||
@apply items-start pt-3;
|
||||
}
|
||||
|
||||
.room-pins__expand {
|
||||
@apply flex min-w-0 flex-1 items-center gap-3 text-left;
|
||||
}
|
||||
|
||||
.room-pins__counter {
|
||||
@apply shrink-0 py-2.5;
|
||||
}
|
||||
|
||||
.room-pins__details {
|
||||
@apply flex flex-col gap-3 px-4 pb-3;
|
||||
}
|
||||
|
||||
.room-pins__label {
|
||||
@apply text-[10px] font-bold tracking-widest uppercase opacity-80;
|
||||
}
|
||||
|
||||
.room-pins__preview {
|
||||
@apply min-w-0 flex-1 truncate text-sm;
|
||||
}
|
||||
|
||||
.room-pins__message {
|
||||
@apply text-primary-content text-sm leading-relaxed;
|
||||
|
||||
:where(.link-content, a, p, span, code) {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.room-pins__footer {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.room-pins__nav {
|
||||
@apply flex items-center gap-1 text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
[data-fl-theme="clay"] .room {
|
||||
|
|
|
|||
17
src/lib/theme.ts
Normal file
17
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import theme from "tailwindcss/defaultTheme"
|
||||
import {readable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
|
||||
export const matchWidth = (query: string): Readable<boolean> =>
|
||||
readable(typeof window !== "undefined" && window.matchMedia(query).matches, set => {
|
||||
const mq = window.matchMedia(query)
|
||||
|
||||
const onChange = () => set(mq.matches)
|
||||
|
||||
onChange()
|
||||
mq.addEventListener("change", onChange)
|
||||
|
||||
return () => mq.removeEventListener("change", onChange)
|
||||
})
|
||||
|
||||
export const matchMd = matchWidth(`(min-width: ${theme.screens.md})`)
|
||||
|
|
@ -75,6 +75,27 @@ export function slideAndFade(
|
|||
}
|
||||
}
|
||||
|
||||
export type ScaleParams = {
|
||||
delay?: number
|
||||
duration?: number
|
||||
easing?: (t: number) => number
|
||||
start?: number
|
||||
}
|
||||
|
||||
export const scale = (
|
||||
node: Element,
|
||||
{delay = 0, duration = 200, easing = cubicOut, start = 0.96}: ScaleParams = {},
|
||||
) => ({
|
||||
delay,
|
||||
duration,
|
||||
easing,
|
||||
css: (t: number) => {
|
||||
const s = start + (1 - start) * t
|
||||
|
||||
return `transform: scale(${s}); opacity: ${t}`
|
||||
},
|
||||
})
|
||||
|
||||
export const conditionalTransition =
|
||||
(condition: any, transition: any) => (node: any, args?: any) =>
|
||||
condition ? transition(node, args) : null
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
import RoomItem from "@app/components/RoomItem.svelte"
|
||||
import RoomName from "@app/components/RoomName.svelte"
|
||||
import RoomSearch from "@app/components/RoomSearch.svelte"
|
||||
import RoomPinnedMessages from "@app/components/RoomPinnedMessages.svelte"
|
||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||
|
|
@ -463,6 +464,8 @@
|
|||
"room flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
voiceConnectedHere && $videoCallLayout === VideoCallLayout.Video && "md:hidden",
|
||||
)}>
|
||||
<RoomPinnedMessages {url} {h} />
|
||||
|
||||
{#if isVoiceRoom && $callState === CallState.Connected}
|
||||
<VideoCallContent layout={$videoCallLayout} mobile {url} {h} class="md:hidden" />
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Reference in a new issue