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:
Aditya Chaudhary 2026-07-15 21:23:43 +00:00 committed by hodlbod
parent e1c439fb6b
commit caa16b6770
30 changed files with 891 additions and 187 deletions

View file

@ -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"

View file

@ -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", {

View file

@ -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>

View file

@ -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}

View file

@ -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">

View file

@ -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} />

View file

@ -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">

View file

@ -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} />

View 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}

View 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>

View file

@ -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}

View file

@ -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>

View file

@ -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}

View file

@ -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>

View file

@ -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}>

View 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>

View file

@ -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
View 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),
)

View file

@ -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}))

View file

@ -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,
],
}

View file

@ -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,
],
},
],
})

View file

@ -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()

View file

@ -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;
}
}

View file

@ -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) */

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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
View 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})`)

View file

@ -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

View file

@ -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}