Compare commits

..

3 Commits

11 changed files with 314 additions and 100 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check src && eslint src",
"format": "git diff HEAD --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
"format:all": "prettier --write src",
"prepare": "husky"
},
+57 -4
View File
@@ -1,22 +1,70 @@
<script lang="ts">
import {call, ellipsize, displayUrl, postJson} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {isRelayUrl, getTagValue, normalizeRelayUrl, displayRelayUrl} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {dufflepud, PLATFORM_URL, IMAGE_CONTENT_TYPES, VIDEO_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {
dufflepud,
PLATFORM_URL,
IMAGE_CONTENT_TYPES,
VIDEO_CONTENT_TYPES,
displayRoom,
isRoomId,
splitRoomId,
} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const label = call(() => {
if (roomReference) {
const spaceName = displayRelayUrl(roomReference.url)
const roomName = displayRoom(roomReference.url, roomReference.h)
return `~${spaceName} / ${roomName}`
}
if (relayReference) {
return `~${displayRelayUrl(relayReference)}`
}
return displayUrl(url)
})
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
@@ -49,6 +97,11 @@
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else if roomReference || relayReference}
<div class="bg-alt p-4 leading-normal">
<Icon icon={LinkRound} size={3} class="inline-block" />
<span class="ml-2">{label}</span>
</div>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
+49 -5
View File
@@ -1,21 +1,65 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {isRelayUrl, getTagValue, normalizeRelayUrl, displayRelayUrl} from "@welshman/util"
import {preventDefault, stopPropagation} from "@lib/html"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import {pushModal} from "@app/util/modal"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {
PLATFORM_URL,
IMAGE_CONTENT_TYPES,
displayRoom,
isRoomId,
splitRoomId,
} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
const url = value.url.toString()
const roomReference = call(() => {
if (!isRoomId(url)) {
return undefined
}
const [roomUrl, h] = splitRoomId(url)
if (!roomUrl || !h || !isRelayUrl(roomUrl)) {
return undefined
}
return {url: normalizeRelayUrl(roomUrl), h}
})
const relayReference = call(() => {
if (roomReference || !isRelayUrl(url)) {
return undefined
}
return normalizeRelayUrl(url)
})
const label = call(() => {
if (roomReference) {
const spaceName = displayRelayUrl(roomReference.url)
const roomName = displayRoom(roomReference.url, roomReference.h)
return `~${spaceName} / ${roomName}`
}
if (relayReference) {
return `~${displayRelayUrl(relayReference)}`
}
return displayUrl(url)
})
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (roomReference) return [makeRoomPath(roomReference.url, roomReference.h), false]
if (relayReference) return [makeSpacePath(relayReference), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
@@ -36,6 +80,6 @@
{:else}
<Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{label}
</Link>
{/if}
+14
View File
@@ -1,9 +1,23 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {onMount} from "svelte"
import {request} from "@welshman/net"
import {PollResponse} from "nostr-tools/kinds"
import PollVotes from "@app/components/PollVotes.svelte"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
onMount(() => {
if (!props.url) {
return
}
request({
relays: [props.url],
filters: [{kinds: [PollResponse], "#e": [props.event.id]}],
})
})
</script>
<div class="flex flex-col gap-3">
+20 -78
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import {now, randomId, removeUndefined} from "@welshman/lib"
import {insertAt, now, randomId, removeAt, removeUndefined} from "@welshman/lib"
import {makeEvent} from "@welshman/util"
import {publishThunk} from "@welshman/app"
import {Poll} from "nostr-tools/kinds"
@@ -52,99 +52,45 @@
options = options.map(option => (option.id === id ? {...option, value} : option))
}
const swapOptions = (sourceId: string, targetId: string) => {
if (sourceId === targetId) {
const reorderOptions = (targetId: string) => {
if (!draggedOptionId) {
return
}
const sourceIndex = options.findIndex(option => option.id === sourceId)
const sourceIndex = options.findIndex(option => option.id === draggedOptionId)
const targetIndex = options.findIndex(option => option.id === targetId)
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) {
return
}
const reordered = [...options]
const sourceOption = reordered[sourceIndex]
reordered[sourceIndex] = reordered[targetIndex]
reordered[targetIndex] = sourceOption
options = reordered
options = insertAt(targetIndex, options[sourceIndex], removeAt(sourceIndex, options))
}
const getOptionId = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) {
return undefined
}
return target.dataset.optionId
}
const getClosestOptionId = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) {
return undefined
}
return target.closest<HTMLElement>("[data-option-id]")?.dataset.optionId
}
const handleOptionDragStart = (e: DragEvent) => {
const optionId = getOptionId(e.currentTarget)
if (!optionId) {
return
}
draggedOptionId = optionId
const onDragStart = (e: DragEvent, id: string) => {
draggedOptionId = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", optionId)
e.dataTransfer.setData("text/plain", id)
}
}
const handleOptionDragOver = (e: DragEvent) => {
const onDragOver = (e: DragEvent, targetId: string) => {
e.preventDefault()
reorderOptions(targetId)
}
const handleOptionDrop = (e: DragEvent) => {
const onDrop = (e: DragEvent, targetId: string) => {
e.preventDefault()
const targetId = getOptionId(e.currentTarget)
if (!draggedOptionId || !targetId) {
draggedOptionId = undefined
return
}
swapOptions(draggedOptionId, targetId)
reorderOptions(targetId)
draggedOptionId = undefined
}
const handleOptionDragEnd = () => {
const onDragEnd = () => {
draggedOptionId = undefined
}
const handleOptionInput = (e: Event) => {
const optionId = getOptionId(e.currentTarget)
if (!optionId || !(e.currentTarget instanceof HTMLInputElement)) {
return
}
updateOption(optionId, e.currentTarget.value)
}
const handleRemoveOption = (e: Event) => {
const optionId = getClosestOptionId(e.target)
if (!optionId) {
return
}
removeOption(optionId)
}
const submit = async () => {
if (!title.trim()) {
return pushToast({theme: "error", message: "Please provide a title for your poll."})
@@ -186,8 +132,6 @@
history.back()
}
const onSubmit = preventDefault(submit)
let title = $state("")
let pollType = $state<PollType>("singlechoice")
let endsAt = $state<number | undefined>()
@@ -198,7 +142,7 @@
let draggedOptionId = $state<string | undefined>()
</script>
<Modal tag="form" onsubmit={onSubmit}>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Poll</ModalTitle>
@@ -231,26 +175,24 @@
{#each options as option, index (option.id)}
<div
class="flex items-center gap-2"
data-option-id={option.id}
draggable="true"
role="listitem"
ondragstart={handleOptionDragStart}
ondragover={handleOptionDragOver}
ondrop={handleOptionDrop}
ondragend={handleOptionDragEnd}>
ondragstart={e => onDragStart(e, option.id)}
ondragover={e => onDragOver(e, option.id)}
ondrop={e => onDrop(e, option.id)}
ondragend={onDragEnd}>
<div class="cursor-move opacity-70" aria-label="Drag handle">
<Icon icon={HamburgerMenu} size={4} />
</div>
<label class="input input-bordered flex w-full items-center gap-2">
<input
data-option-id={option.id}
value={option.value}
class="grow"
type="text"
placeholder={`Option ${index + 1}`}
oninput={handleOptionInput} />
oninput={e => updateOption(option.id, e.currentTarget.value)} />
</label>
<Button class="btn btn-ghost btn-sm" onclick={handleRemoveOption}>
<Button class="btn btn-ghost btn-sm" onclick={() => removeOption(option.id)}>
<Icon icon={MinusCircle} size={4} />
</Button>
</div>
+20 -10
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import {tweened} from "svelte/motion"
import type {TrustedEvent} from "@welshman/util"
import {noop} from "@welshman/lib"
import {stopPropagation} from "@lib/html"
import {getPollType, isPollClosed} from "@app/util/polls"
type Props = {
@@ -20,15 +23,22 @@
const selected = $derived(
pollType === "singlechoice" ? selectedIds[0] === option.id : selectedIds.includes(option.id),
)
const handleInputClick = (e: MouseEvent) => {
e.stopPropagation()
}
const handleSelectChange = () =>
const onselect = () =>
pollType === "singlechoice" ? setSingleChoice(option.id) : toggleMultipleChoice(option.id)
const votes = $derived(results.options.find(r => r.id === option.id)?.votes || 0)
const maxVotes = $derived(Math.max(...results.options.map(r => r.votes), 1))
const tweenedVotes = tweened(votes, {duration: 300})
const tweenedMax = tweened(maxVotes, {duration: 300})
$effect(() => {
tweenedVotes.set(votes)
})
$effect(() => {
tweenedMax.set(maxVotes)
})
</script>
<div class="flex flex-col gap-2 card2 card2-sm bg-alt">
@@ -41,20 +51,20 @@
type="radio"
class="radio radio-primary radio-sm"
checked={selected}
onclick={handleInputClick}
onchange={handleSelectChange} />
onclick={stopPropagation(noop)}
onchange={onselect} />
{:else}
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={selected}
onclick={handleInputClick}
onchange={handleSelectChange} />
onclick={stopPropagation(noop)}
onchange={onselect} />
{/if}
{/if}
<span class="truncate">{option.label}</span>
</label>
<span class="whitespace-nowrap text-xs opacity-75">{votes} vote{votes === 1 ? "" : "s"}</span>
</div>
<progress class="progress progress-primary" value={votes} max={maxVotes}></progress>
<progress class="progress progress-primary" value={$tweenedVotes} max={$tweenedMax}></progress>
</div>
+2
View File
@@ -594,6 +594,8 @@ export const getRoomType = (room: RoomMeta): RoomType =>
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
export const isRoomId = (id: string) => id.includes("'")
export const splitRoomId = (id: string) => id.split("'")
export const hasNip29 = (relay?: RelayProfile) =>
+42
View File
@@ -0,0 +1,42 @@
import {mergeAttributes, Node} from "@tiptap/core"
import {RoomReferenceNodeView} from "@app/editor/RoomReferenceNodeView"
export const RoomReferenceExtension = Node.create({
name: "roomref",
atom: true,
inline: true,
group: "inline",
selectable: true,
priority: 1000,
addAttributes() {
return {
url: {default: undefined},
h: {default: undefined},
}
},
parseHTML() {
return [{tag: `span[data-type="${this.name}"]`}]
},
renderHTML({HTMLAttributes}) {
return ["span", mergeAttributes(HTMLAttributes, {"data-type": this.name}), "~"]
},
renderText({node}) {
const url = typeof node.attrs.url === "string" ? node.attrs.url : ""
const h = typeof node.attrs.h === "string" ? node.attrs.h : ""
return `${url}'${h}`
},
addNodeView() {
return RoomReferenceNodeView
},
})
+29
View File
@@ -0,0 +1,29 @@
import type {NodeViewRendererProps} from "@tiptap/core"
import {displayRelayUrl} from "@welshman/util"
import {deriveRoom} from "@app/core/state"
export const RoomReferenceNodeView = ({node}: NodeViewRendererProps) => {
const dom = document.createElement("span")
const url = typeof node.attrs.url === "string" ? node.attrs.url : ""
const h = typeof node.attrs.h === "string" ? node.attrs.h : ""
const room = deriveRoom(url, h)
dom.classList.add("tiptap-object")
const unsubRoom = room.subscribe($room => {
dom.textContent = `~${displayRelayUrl(url)} / ${$room.name || h}`
})
return {
dom,
destroy: () => {
unsubRoom()
},
selectNode() {
dom.classList.add("tiptap-active")
},
deselectNode() {
dom.classList.remove("tiptap-active")
},
}
}
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRoom, splitRoomId} from "@app/core/state"
type Props = {
value: string
}
const {value}: Props = $props()
const [url = "", h = ""] = splitRoomId(value)
const room = deriveRoom(url, h)
const label = $derived(`~${displayRelayUrl(url)} / ${$room.name || h}`)
</script>
<div class="max-w-full overflow-hidden text-ellipsis text-base font-semibold">
{label}
</div>
+62 -2
View File
@@ -14,12 +14,26 @@ import {
getWotGraph,
} from "@welshman/app"
import type {FileAttributes} from "@welshman/editor"
import {Editor, MentionSuggestion, WelshmanExtension, editorProps} from "@welshman/editor"
import {
Editor,
MentionSuggestion,
TippySuggestion,
WelshmanExtension,
editorProps,
} from "@welshman/editor"
import {escapeHtml} from "@lib/html"
import {makeMentionNodeView} from "@app/editor/MentionNodeView"
import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte"
import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {uploadFile} from "@app/core/commands"
import {deriveSpaceMembers} from "@app/core/state"
import {
deriveSpaceMembers,
splitRoomId,
userSpaceUrls,
roomsByUrl,
type Room,
} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const makeEditor = async ({
@@ -82,12 +96,35 @@ export const makeEditor = async ({
},
)
const roomReferenceSearch = derived(
[throttled(800, userSpaceUrls), throttled(800, roomsByUrl)],
([$userSpaceUrls, $roomsByUrl]) => {
const options: Room[] = []
for (const roomUrl of $userSpaceUrls) {
for (const room of $roomsByUrl.get(roomUrl) || []) {
options.push(room)
}
}
return createSearch(options, {
getValue: item => item.id,
fuseOptions: {
keys: ["name", "h", "url"],
threshold: 0.3,
shouldSort: false,
},
})
},
)
return new Editor({
content: escapeHtml(content),
autofocus,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({
submit,
extensions: {
@@ -129,6 +166,29 @@ export const makeEditor = async ({
mount(ProfileSuggestion, {target, props: {value, url}})
return target
},
}),
TippySuggestion({
char: "~",
name: "roomref",
editor: (this as any).editor,
search: (term: string) => get(roomReferenceSearch).searchValues(term),
updateSignal: roomReferenceSearch,
select: (id: string, props) => {
const [roomUrl, h] = splitRoomId(id)
if (!roomUrl || !h) {
return
}
return props.command({url: roomUrl, h})
},
createSuggestion: (value: string) => {
const target = document.createElement("div")
mount(RoomSuggestion, {target, props: {value}})
return target
},
}),