Compare commits

...

4 Commits

Author SHA1 Message Date
rajanarahul93 fefe1236a8 fix: prevent redundant sync re-initialization on room navigation 2026-04-12 02:38:00 +05:30
Jon Staab 9f386f6968 remove redundant room syncing logic 2026-04-11 10:20:50 -07:00
Khushvendra ec0b6a99e2 add room mentions and clickable room/relay refs 2026-04-11 10:13:23 -07:00
Khushvendra f6d9e52c6e fix: support native clipboard image paste on mobile (#181)
Co-authored-by: Khushvendra <khushvendras99@gmail.com>
Co-committed-by: Khushvendra <khushvendras99@gmail.com>
2026-04-11 16:38:06 +00:00
14 changed files with 381 additions and 89 deletions
+2
View File
@@ -48,6 +48,7 @@
"@capacitor/android": "^8.0.1",
"@capacitor/app": "^8.0.0",
"@capacitor/cli": "^8.0.1",
"@capacitor/clipboard": "^8.0.1",
"@capacitor/core": "^8.0.1",
"@capacitor/filesystem": "^8.1.0",
"@capacitor/ios": "^8.0.1",
@@ -63,6 +64,7 @@
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@tiptap/core": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^0.2.6",
+15
View File
@@ -26,6 +26,9 @@ importers:
'@capacitor/cli':
specifier: ^8.0.1
version: 8.0.1
'@capacitor/clipboard':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
'@capacitor/core':
specifier: ^8.0.1
version: 8.0.1
@@ -71,6 +74,9 @@ importers:
'@tiptap/core':
specifier: ^2.27.2
version: 2.27.2(@tiptap/pm@2.27.2)
'@tiptap/pm':
specifier: ^2.27.2
version: 2.27.2
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
@@ -791,6 +797,11 @@ packages:
engines: {node: '>=22.0.0'}
hasBin: true
'@capacitor/clipboard@8.0.1':
resolution: {integrity: sha512-iOlbTi8MojKyLnYE+M27priXid7vHd0PlDwyHohPzkuQ8Rkp6q7ykwZmPEUD+OnU/Ink7Qw/pUOfKgraKmA6Eg==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/core@8.0.1':
resolution: {integrity: sha512-5UqSWxGMp/B8KhYu7rAijqNtYslhcLh+TrbfU48PfdMDsPfaU/VY48sMNzC22xL8BmoFoql/3SKyP+pavTOvOA==}
@@ -5969,6 +5980,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@capacitor/clipboard@8.0.1(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/core@8.0.1':
dependencies:
tslib: 2.8.1
+54 -46
View File
@@ -5,30 +5,32 @@
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/util/modal"
import {
dufflepud,
PLATFORM_URL,
IMAGE_CONTENT_TYPES,
PLATFORM_URL,
VIDEO_CONTENT_TYPES,
THUMBNAIL_URL,
isRoomId,
} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const {value, event} = $props()
let hideImage = $state(false)
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const isRoomOrRelay = isRoomId(url) || isRelayUrl(url)
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const fileType = getTagValue("file-type", event.tags) || ""
const getVideoPoster = (videoUrl: string): string | undefined => {
if (Capacitor.getPlatform() === "android" && THUMBNAIL_URL) {
return `${THUMBNAIL_URL}/thumbnail?url=${encodeURIComponent(videoUrl)}`
@@ -54,46 +56,52 @@
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
{#if isRoomOrRelay}
<div>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
</div>
</Link>
{:else}
<Link {external} {href} class="my-2 block">
<div class="overflow-hidden rounded-box">
{#if url.match(/\.(mov|webm|mp4)$/) || VIDEO_CONTENT_TYPES.includes(fileType)}
<video
controls
src={url}
poster={getVideoPoster(url)}
preload="metadata"
class="max-h-96 rounded-box object-contain object-center">
<track kind="captions" />
</video>
{:else if url.match(/\.(jpe?g|png|gif|webp)$/) || IMAGE_CONTENT_TYPES.includes(fileType)}
<button type="button" onclick={stopPropagation(preventDefault(expand))}>
<ContentLinkBlockImage {value} {event} class="m-auto max-h-96 rounded-box" />
</button>
{:else}
{#await loadPreview()}
<div class="center my-12 w-full">
<span class="loading loading-spinner"></span>
</div>
{:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal">
{#if preview.image && !hideImage}
<img
alt=""
onerror={onError}
src={preview.image}
class="bg-alt max-h-72 rounded-t-box object-contain object-center" />
{/if}
<div class="flex flex-col gap-2 p-4">
<strong class="overflow-hidden text-ellipsis whitespace-nowrap"
>{preview.title || displayUrl(url)}</strong>
<p>{ellipsize(preview.description, 140)}</p>
</div>
</div>
{:catch}
<p class="bg-alt p-12 text-center leading-normal">
Unable to load a preview for {url}
</p>
{/await}
{/if}
</div>
</Link>
{/if}
+5 -15
View File
@@ -1,25 +1,18 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {isRelayUrl, getTagValue} from "@welshman/util"
import {displayUrl} from "@welshman/lib"
import {getTagValue} 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 ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import {pushModal} from "@app/util/modal"
import {PLATFORM_URL, IMAGE_CONTENT_TYPES} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {IMAGE_CONTENT_TYPES} from "@app/core/state"
const {value, event} = $props()
const url = value.url.toString()
const fileType = getTagValue("file-type", event.tags) || ""
const [href, external] = call(() => {
if (isRelayUrl(url)) return [makeSpacePath(url), false]
if (url.startsWith(PLATFORM_URL)) return [url.replace(PLATFORM_URL, ""), false]
return [url, true]
})
const expand = () => pushModal(ContentLinkDetail, {value, event}, {fullscreen: true})
</script>
@@ -34,8 +27,5 @@
{displayUrl(url)}
</a>
{:else}
<Link {external} {href} class="link-content whitespace-nowrap">
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
</Link>
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
{/if}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import {call, displayUrl} from "@welshman/lib"
import {displayRelayUrl, isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import {PLATFORM_URL, displayRoom, isRoomId, splitRoomId} from "@app/core/state"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
const {
url,
class: className = "",
}: {
url: string
class?: string
} = $props()
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 [href, external] = call(() => {
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]
})
</script>
<Link {external} {href} class={className}>
{#if roomReference}
~<span class="text-primary">{displayRelayUrl(roomReference.url)}</span> /
{displayRoom(roomReference.url, roomReference.h)}
{:else if relayReference}
<span class="text-primary">{displayRelayUrl(relayReference)}</span>
{:else}
<Icon icon={LinkRound} size={3} class="inline-block" />
{displayUrl(url)}
{/if}
</Link>
+1 -1
View File
@@ -12,7 +12,7 @@
</script>
<div class="flex grow items-center justify-between gap-4 {props.class}">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<RoomImage {url} {h} />
<div class="min-w-0 overflow-hidden text-ellipsis">
<RoomName {url} {h} />
+2
View File
@@ -593,6 +593,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) =>
+4 -21
View File
@@ -268,7 +268,7 @@ const syncUserData = () => {
// Spaces
const syncSpace = (url: string, rooms: string[]) => {
const syncSpace = (url: string) => {
const since = ago(WEEK)
const seen = new Set<string>()
const controller = new AbortController()
@@ -293,9 +293,6 @@ const syncSpace = (url: string, rooms: string[]) => {
}
}
for (const room of rooms) {
pullRoomContent(room)
}
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
@@ -328,7 +325,6 @@ const syncSpace = (url: string, rooms: string[]) => {
const syncSpaces = () => {
const store = merged([userGroupList, page])
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const roomsByUrl = new Map<string, string>()
const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList))
@@ -342,28 +338,15 @@ const syncSpaces = () => {
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.has(url)) {
unsubscribersByUrl.delete(url)
roomsByUrl.delete(url)
unsubscribe()
}
}
// Start or restart syncing for each space
// Start syncing for new spaces
for (const url of urls) {
const rooms = getSpaceRoomsFromGroupList(url, $userGroupList)
if (currentUrl === url && $page.params.h && !rooms.includes($page.params.h)) {
rooms.push($page.params.h)
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncSpace(url))
}
const roomsKey = rooms.join(",")
if (unsubscribersByUrl.has(url) && roomsByUrl.get(url) === roomsKey) continue
// Tear down existing sync if rooms changed
unsubscribersByUrl.get(url)?.()
roomsByUrl.set(url, roomsKey)
unsubscribersByUrl.set(url, syncSpace(url, rooms))
}
})
+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")
},
}
}
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {splitRoomId} from "@app/core/state"
type Props = {
value: string
}
const {value}: Props = $props()
const [url = "", h = ""] = splitRoomId(value)
</script>
<div class="max-w-full overflow-hidden text-ellipsis flex flex-col">
<RoomNameWithImage {url} {h} />
<span class="text-primary text-sm">{displayRelayUrl(url)}<span> </span></span>
</div>
+81
View File
@@ -0,0 +1,81 @@
import {Clipboard} from "@capacitor/clipboard"
import {Capacitor} from "@capacitor/core"
import {Extension} from "@tiptap/core"
import {Plugin, PluginKey} from "@tiptap/pm/state"
const nativeClipboardAvailable = () =>
Capacitor.isNativePlatform() && Capacitor.isPluginAvailable("Clipboard")
const hasStandardPastePayload = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) {
return false
}
if (Array.from(clipboardData.items).some(item => item.kind === "file")) {
return true
}
if (clipboardData.types.includes("text/html")) {
return true
}
return clipboardData.getData("text/plain") !== ""
}
const getNativeClipboardImage = async () => {
try {
const {type, value} = await Clipboard.read()
if (!type.startsWith("image/") || value === "") {
return undefined
}
const imageData = value.startsWith("data:") ? value : `data:${type};base64,${value}`
const blob = await fetch(imageData).then(res => res.blob())
if (!blob.type.startsWith("image/")) {
return undefined
}
const extension = type.split("/")[1]?.split("+")[0] || "png"
return new File([blob], `clipboard-image.${extension}`, {type: blob.type || type})
} catch {
return undefined
}
}
export const NativeClipboardPasteExtension = Extension.create({
name: "nativeClipboardPaste",
addProseMirrorPlugins() {
const editor = this.editor
return [
new Plugin({
key: new PluginKey("nativeClipboardPaste"),
props: {
handlePaste: (_view, event) => {
if (!nativeClipboardAvailable() || hasStandardPastePayload(event)) {
return false
}
event.preventDefault()
void getNativeClipboardImage().then(file => {
if (!file) {
return
}
editor.commands.addFile(file, editor.state.selection.from + 1)
})
return true
},
},
}),
]
},
})
+67 -3
View File
@@ -4,7 +4,7 @@ import {get, derived} from "svelte/store"
import {Router} from "@welshman/router"
import {dec, inc} from "@welshman/lib"
import {throttled} from "@welshman/store"
import type {PublishedProfile} from "@welshman/util"
import type {PublishedProfile, RoomMeta} from "@welshman/util"
import {
createSearch,
profiles,
@@ -14,12 +14,27 @@ 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 {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {uploadFile} from "@app/core/commands"
import {deriveSpaceMembers} from "@app/core/state"
import {
deriveSpaceMembers,
makeRoomId,
splitRoomId,
userSpaceUrls,
roomsByUrl,
} from "@app/core/state"
import {pushToast} from "@app/util/toast"
export const makeEditor = async ({
@@ -82,11 +97,36 @@ export const makeEditor = async ({
},
)
const roomReferenceSearch = derived(
[throttled(800, userSpaceUrls), throttled(800, roomsByUrl)],
([$userSpaceUrls, $roomsByUrl]) => {
const roomIdByMeta = new WeakMap<RoomMeta, string>()
const options: RoomMeta[] = []
for (const roomUrl of $userSpaceUrls) {
for (const room of $roomsByUrl.get(roomUrl) || []) {
roomIdByMeta.set(room, makeRoomId(roomUrl, room.h))
options.push(room)
}
}
return createSearch(options, {
getValue: item => roomIdByMeta.get(item) || item.h,
fuseOptions: {
keys: ["name", "h"],
threshold: 0.3,
shouldSort: false,
},
})
},
)
const ed = new Editor({
content: typeof content === "string" ? escapeHtml(content) : content,
editorProps,
element: document.createElement("div"),
extensions: [
RoomReferenceExtension,
WelshmanExtension.configure({
submit,
extensions: {
@@ -128,6 +168,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
},
}),
@@ -137,6 +200,7 @@ export const makeEditor = async ({
},
},
}),
NativeClipboardPasteExtension,
],
onUpdate({editor}) {
wordCount?.set(editor.storage.wordCount.words)
+3 -3
View File
@@ -63,11 +63,11 @@ export const goToSpace = async (url: string) => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath && prevPath !== makeSpacePath(url)) {
goto(prevPath)
goto(prevPath, {replaceState: true})
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent"))
goto(makeSpacePath(url, "recent"), {replaceState: true})
} else {
goto(makeSpacePath(url))
goto(makeSpacePath(url), {replaceState: true})
}
}