Compare commits

...

9 Commits

Author SHA1 Message Date
sakshamjain dcf332fbe4 Merge remote-tracking branch 'upstream/dev' into feat/space-invite-share 2026-04-14 23:24:17 +05:30
sakshamjain 9fa1375435 fix: handle unsupported Share API gracefully 2026-04-14 23:05:49 +05:30
bhavishy2801 132c7f031b fix: remove deleted rooms from space navigation after refresh
## Summary

This PR fixes a bug where deleting a room succeeded on the relay, but the room could still appear in space navigation (including after refresh).

Fixes #194.

## Root Cause

Room list derivation did not consistently treat delete events as authoritative in edge cases:
- Delete timestamp aggregation could involve an undefined prior value.
- Room meta events with the same second-level timestamp as a delete event were still considered active.

## Changes

- Updated room delete timestamp aggregation logic to handle first delete event safely.
- Updated room filtering condition so a room is excluded when its meta timestamp is less than or equal to the delete timestamp.
- This ensures deleted rooms are not shown in navigation immediately or after reload.

## Validation

- `pnpm run check` passed.
- `pnpm run lint` passed.

## Testing

- [x] In a NIP-29 space, create a temporary room.
- [x] Delete the room from room details.
- [x] Confirm it disappears from navigation immediately.
- [x] Hard refresh and confirm it does not reappear.
- [x] Reopen app/session and confirm it remains absent.
- [x] Repeat by creating/deleting another room quickly (same-minute timestamp edge case).
2026-04-14 15:43:49 +00:00
Prat_09 964ef441ec Update relay description (#195) (#197)
Co-authored-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
Co-committed-by: Pratyush Mohanty <prat_09@noreply.coracle.social>
2026-04-14 15:09:46 +00:00
sakshamjain cdeb7afcae feat: add native share support for space invites 2026-04-14 19:27:23 +05:30
priyanshu_bharti 796f37d320 Make space reordering discoverable with smoother drag animation (#171)
Co-authored-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
Co-committed-by: Priyanshu Bharti <bhartipriyanshustm@gmail.com>
2026-04-13 22:38:02 +00:00
Nayan Patidar b46fd94578 Use relay-provided member lists as source of truth (#191)
Co-authored-by: Nayan Patidar <nayan9617@noreply.coracle.social>
Co-committed-by: Nayan Patidar <nayan9617@noreply.coracle.social>
2026-04-13 21:12:49 +00:00
Jon Staab bdc8e75640 Fix search input width 2026-04-13 12:08:11 -07:00
Jon Staab ef08821796 remove VirtualItem 2026-04-13 10:35:26 -07:00
21 changed files with 366 additions and 264 deletions
+1
View File
@@ -55,6 +55,7 @@
"@capacitor/keyboard": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/share": "^8.0.1",
"@capawesome/capacitor-android-dark-mode-support": "^8.0.0",
"@capawesome/capacitor-badge": "^8.0.0",
"@getalby/lightning-tools": "^6.1.0",
+12
View File
@@ -47,6 +47,9 @@ importers:
'@capacitor/push-notifications':
specifier: ^8.0.0
version: 8.0.0(@capacitor/core@8.0.1)
'@capacitor/share':
specifier: ^8.0.1
version: 8.0.1(@capacitor/core@8.0.1)
'@capawesome/capacitor-android-dark-mode-support':
specifier: ^8.0.0
version: 8.0.0(@capacitor/core@8.0.1)
@@ -838,6 +841,11 @@ packages:
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/share@8.0.1':
resolution: {integrity: sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@capacitor/synapse@1.0.4':
resolution: {integrity: sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==}
@@ -6021,6 +6029,10 @@ snapshots:
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/share@8.0.1(@capacitor/core@8.0.1)':
dependencies:
'@capacitor/core': 8.0.1
'@capacitor/synapse@1.0.4': {}
'@capawesome/capacitor-android-dark-mode-support@8.0.0(@capacitor/core@8.0.1)':
+1 -1
View File
@@ -279,7 +279,7 @@
</div>
</PageBar>
<PageContent class="flex flex-col-reverse gap-2 pt-4">
<PageContent class="flex flex-col-reverse gap-2 py-4">
{#if missingRelayLists.length > 0}
<div class="py-12">
<div class="card2 col-2 m-auto max-w-md items-center text-center">
+5 -1
View File
@@ -243,7 +243,7 @@
{/if}
</div>
</div>
{#if $members.length > 0}
{#if $members !== undefined && $members.length > 0}
<div class="card2 card2-sm bg-alt flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<span>Members:</span>
@@ -251,6 +251,10 @@
</div>
<Button class="btn btn-neutral btn-sm" onclick={showMembers}>View All</Button>
</div>
{:else if $members === undefined}
<div class="card2 card2-sm bg-base-200 flex items-center gap-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{/if}
<div class="card2 card2-sm bg-alt col-4">
<strong class="text-lg">Room Settings</strong>
+36 -26
View File
@@ -73,34 +73,44 @@
</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
{#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this relay</span>
</div>
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button class="text-error" onclick={() => removeMember(pubkey)}>
<Icon icon={MinusCircle} />
Remove Member
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
</div>
</div>
{/each}
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
+5
View File
@@ -56,6 +56,11 @@
}
const onSubmit = async () => {
if (!$spaceMembers) {
addMembers()
return
}
const pubkeysSnapshot = $state.snapshot(pubkeys)
const nonSpaceMembers = pubkeysSnapshot.filter(pubkey => !$spaceMembers.includes(pubkey))
+48 -8
View File
@@ -3,7 +3,9 @@
import {sleep} from "@welshman/lib"
import {request} from "@welshman/net"
import {displayRelayUrl, getTagValue, RELAY_INVITE} from "@welshman/util"
import {Share} from "@capacitor/share"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import NativeShare from "@assets/icons/native-share.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Field from "@lib/components/Field.svelte"
@@ -28,6 +30,17 @@
const copyInvite = () => clip(invite)
const shareInvite = async () => {
if (!canShare) return
try {
await Share.share({url: invite})
} catch (e) {
console.error(e)
}
}
let canShare = $state(false)
let claim = $state("")
let loading = $state(true)
@@ -41,6 +54,13 @@
})
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
const [[event]] = await Promise.all([
request({
relays: [url],
@@ -74,17 +94,37 @@
<p class="center">Oops! It looks like you're not a member of this relay.</p>
{:else}
<div class="flex flex-col items-center gap-6">
<QRCode code={invite} />
<div class="w-48">
<QRCode code={invite} />
</div>
<Field>
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={LinkRound} />
<input bind:value={invite} class="grow" type="text" />
<Button onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input input-bordered flex shrink-0 w-12 items-center justify-center p-0"
onclick={shareInvite}
>
<Icon icon={NativeShare} />
</Button>
{/if}
<label class="input input-bordered flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input
bind:value={invite}
class="min-w-0 flex-1 truncate"
type="text"
readonly
/>
<Button class="shrink-0" onclick={copyInvite}>
<Icon icon={Copy} />
</Button>
</label>
</div>
{/snippet}
{#snippet info()}
<p>
This invite link can be used by clicking "Add Space" and pasting it there.
+51 -39
View File
@@ -112,46 +112,58 @@
{/if}
{/if}
<div class="flex flex-col gap-2">
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
{#if $members === undefined}
<div class="card2 bg-base-200 p-4">
<span class="text-error">Member list not available from this space</span>
</div>
{/each}
{:else if $members.length === 0}
<div class="card2 bg-base-200 p-4">
<span class="text-base-content/70">No members yet</span>
</div>
{:else}
{#each $members as pubkey (pubkey)}
<div class="card2 card2-sm bg-alt relative">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canBan || canUnallow}
<div class="relative">
<Button
class="btn btn-circle btn-ghost btn-sm"
onclick={() => toggleMenu(pubkey)}>
<Icon icon={MenuDots} />
</Button>
{#if menuPubkey === pubkey}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
{#if canUnallow}
<li>
<Button onclick={() => unallowMember(pubkey)}>
<Icon icon={UserMinus} />
Remove User
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={() => banMember(pubkey)}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
+5 -1
View File
@@ -181,7 +181,11 @@
<li>
<Button onclick={showMembers}>
<Icon icon={UserRounded} />
View Members ({$members.length})
{#if $members === undefined}
View Members
{:else}
View Members ({$members.length})
{/if}
</Button>
</li>
{#if $userIsAdmin}
+2 -1
View File
@@ -766,9 +766,10 @@ export const addSpaceMembers = async (
pubkeys: string[],
): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all(
pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey))
.filter(pubkey => !spaceMembers || !spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
+114 -85
View File
@@ -615,7 +615,12 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
for (const event of deleteEvents) {
for (const h of getTagValues("h", event.tags)) {
deletedByH.set(h, max([deletedByH.get(h), event.created_at]))
const deletedAt = deletedByH.get(h)
deletedByH.set(
h,
deletedAt === undefined ? event.created_at : max([deletedAt, event.created_at]),
)
}
}
@@ -623,8 +628,9 @@ export const roomsByUrl = derived(roomMetaEventsByIdByUrl, roomMetaEventsByIdByU
for (const event of metaEvents) {
const meta = tryCatch(() => readRoomMeta(event))
const deletedAt = meta ? deletedByH.get(meta.h) : undefined
if (!meta || gt(deletedByH.get(meta.h), meta.event.created_at)) {
if (!meta || (deletedAt !== undefined && !gt(meta.event.created_at, deletedAt))) {
continue
}
@@ -808,36 +814,49 @@ export const deriveOtherRooms = (url: string) =>
// Space/room memberships
const getSpaceMembers = (_url: string, events: TrustedEvent[]) => {
const members = new Set<string>()
export const deriveSpaceMembers = (url: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_MEMBERS]}]), ([event]) =>
uniq(getTagValues("member", event?.tags ?? [])),
)
for (const event of sortEventsAsc(events)) {
if (event.kind === RELAY_MEMBERS) {
members.clear()
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_MEMBERS], "#d": [h]}]
for (const pubkey of uniq(getTagValues("member", event.tags))) {
members.add(pubkey)
}
return derived(deriveEventsForUrl(url, filters), ([event]) =>
uniq(getPubkeyTagValues(event?.tags ?? [])),
)
}
continue
export type BannedPubkeyItem = {
pubkey: string
reason: string
}
export const spaceBannedPubkeyItems = new Map<string, BannedPubkeyItem[]>()
export const deriveSpaceBannedPubkeyItems = (url: string) => {
const store = writable(spaceBannedPubkeyItems.get(url) || [])
manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => {
spaceBannedPubkeyItems.set(url, res.result)
store.set(res.result)
})
return store
}
export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), $events => {
const adminsEvent = first($events)
if (adminsEvent) {
return getPubkeyTagValues(adminsEvent.tags)
}
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === RELAY_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === RELAY_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
return []
})
}
const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
@@ -876,53 +895,6 @@ const getRoomMembers = (_url: string, h: string, events: TrustedEvent[]) => {
return Array.from(members)
}
export const deriveSpaceMembers = (url: string) =>
derived(
deriveRelaySignedEvents(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]}]),
$events => getSpaceMembers(url, $events),
)
export type BannedPubkeyItem = {
pubkey: string
reason: string
}
export const spaceBannedPubkeyItems = new Map<string, BannedPubkeyItem[]>()
export const deriveSpaceBannedPubkeyItems = (url: string) => {
const store = writable(spaceBannedPubkeyItems.get(url) || [])
manageRelay(url, {method: ManagementMethod.ListBannedPubkeys, params: []}).then(res => {
spaceBannedPubkeyItems.set(url, res.result)
store.set(res.result)
})
return store
}
export const deriveRoomMembers = (url: string, h: string) => {
const filters: Filter[] = [
{kinds: [ROOM_MEMBERS], "#d": [h]},
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]},
]
return derived(deriveEventsForUrl(url, filters), $events => getRoomMembers(url, h, $events))
}
export const deriveRoomAdmins = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_ADMINS], "#d": [h]}]
return derived(deriveEventsForUrl(url, filters), $events => {
const adminsEvent = first($events)
if (adminsEvent) {
return getPubkeyTagValues(adminsEvent.tags)
}
return []
})
}
// Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = []
@@ -1019,19 +991,49 @@ export const deriveUserIsSpaceAdmin = memoize((url?: string) => {
})
export const deriveUserSpaceMembershipStatus = (url: string) => {
const filters: Filter[] = [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]
// Fetch member list and user add/remove events directly in this derivation.
const memberListFilters: Filter[] = [{kinds: [RELAY_MEMBERS]}]
const userEventFilters: Filter[] = [{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}]
return derived(
[
pubkey,
deriveSpaceMembers(url),
deriveEventsForUrl(url, filters),
deriveRelaySignedEvents(url, memberListFilters),
deriveRelaySignedEvents(url, userEventFilters),
deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
deriveUserIsSpaceAdmin(url),
],
([$pubkey, $members, $events, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin
([$pubkey, $memberListEvents, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
// If admin, always granted.
if ($isAdmin) {
return MembershipStatus.Granted
}
for (const event of $events) {
const membersEvent = $memberListEvents.find(spec({kind: RELAY_MEMBERS}))
const memberList = membersEvent ? uniq(getTagValues("member", membersEvent.tags)) : undefined
let isMember = false
if (memberList) {
// Member list exists - check if user is in it.
isMember = memberList.includes($pubkey!)
} else {
// No member list available - replay the user's add/remove history.
for (const event of sortBy(e => e.created_at, $userAddRemoveEvents)) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === RELAY_ADD_MEMBER) {
isMember = true
} else if (event.kind === RELAY_REMOVE_MEMBER) {
isMember = false
}
}
}
for (const event of $joinLeaveEvents) {
// Join events indicate pending or granted status, leave resets to initial.
if (event.pubkey !== $pubkey) {
continue
}
@@ -1057,19 +1059,46 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
)
export const deriveUserRoomMembershipStatus = (url: string, h: string) => {
const filters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]
// Fetch the room member list and the current user's add/remove events.
const userEventFilters: Filter[] = [{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [h]}]
const joinLeaveFilters: Filter[] = [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [h]}]
return derived(
[
pubkey,
deriveRoomMembers(url, h),
deriveEventsForUrl(url, filters),
deriveEventsForUrl(url, userEventFilters),
deriveEventsForUrl(url, joinLeaveFilters),
deriveUserIsRoomAdmin(url, h),
],
([$pubkey, $members, $events, $isAdmin]) => {
const isMember = $members.includes($pubkey!) || $isAdmin
([$pubkey, $memberList, $userAddRemoveEvents, $joinLeaveEvents, $isAdmin]) => {
// If admin of this room's space, always granted.
if ($isAdmin) {
return MembershipStatus.Granted
}
for (const event of $events) {
let isMember = false
if ($memberList) {
// Member list exists - check if user is in it.
isMember = $memberList.includes($pubkey!)
} else {
// No member list available - replay the user's add/remove history.
for (const event of sortEventsAsc($userAddRemoveEvents)) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === ROOM_ADD_MEMBER) {
isMember = true
} else if (event.kind === ROOM_REMOVE_MEMBER) {
isMember = false
}
}
}
for (const event of $joinLeaveEvents) {
// Join events indicate pending or granted status, leave resets to initial.
if (event.pubkey !== $pubkey) {
continue
}
+4 -4
View File
@@ -284,7 +284,7 @@ const syncSpace = (url: string) => {
{kinds: [MESSAGE, ...CONTENT_KINDS], since, "#h": [room]},
makeCommentFilter(CONTENT_KINDS, {since, "#h": [room]}),
{
kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
kinds: [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE],
"#h": [room],
},
{kinds: [PollResponse], since},
@@ -293,15 +293,15 @@ const syncSpace = (url: string) => {
}
}
const relayKinds = [RELAY_MEMBERS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]
const relayKinds = [RELAY_MEMBERS]
const roomMetaKinds = [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS]
const roomMemberKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER]
const roomDeleteKinds = [ROOM_DELETE, ROOM_JOIN, ROOM_LEAVE]
pullAndListen({
url,
signal: controller.signal,
filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomMemberKinds, ...CONTENT_KINDS, MESSAGE]},
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
makeCommentFilter(CONTENT_KINDS, {since}),
{kinds: [PollResponse], since},
],
+1 -1
View File
@@ -79,7 +79,7 @@ export const makeEditor = async ({
getValue: (profile: PublishedProfile) => profile.event.pubkey,
sortFn: ({score = 1, item}) => {
const wotScore = getWotGraph().get(item.event.pubkey) || 0
const membershipScale = $spaceMembers.includes(item.event.pubkey) ? 2 : 1
const membershipScale = $spaceMembers?.includes(item.event.pubkey) ? 2 : 1
return dec(score) * inc(wotScore / getMaxWot()) * membershipScale
},
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="6" r="1.5" fill="#000000"/>
<circle cx="15" cy="6" r="1.5" fill="#000000"/>
<circle cx="9" cy="12" r="1.5" fill="#000000"/>
<circle cx="15" cy="12" r="1.5" fill="#000000"/>
<circle cx="9" cy="18" r="1.5" fill="#000000"/>
<circle cx="15" cy="18" r="1.5" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.17933 3.72734L7.41899 1.39855V9.76536C7.41899 10.0954 7.67913 10.3425 7.97911 10.3425C8.29941 10.3425 8.53924 10.0745 8.53924 9.76536V1.41957L10.7789 3.72734C10.8992 3.8513 11.039 3.89235 11.1789 3.89235C11.3187 3.89235 11.4789 3.83037 11.5788 3.72734C11.7992 3.50033 11.7992 3.13007 11.5788 2.88213L9.35938 0.574361C8.69927 -0.187929 7.37983 -0.187929 6.63916 0.553433L4.3791 2.90303C4.15879 3.13003 4.15879 3.5003 4.3791 3.74824C4.5994 3.97444 4.95952 3.97444 5.17905 3.72731L5.17933 3.72734Z" fill="#A6ADBB"/>
<path d="M13.8789 4.61365H11.2197C10.8994 4.61365 10.6596 4.8817 10.6596 5.19081C10.6596 5.49991 10.9197 5.76796 11.2197 5.76796H13.8797C14.4195 5.76796 14.8593 6.22115 14.8593 6.77737V12.7944C14.8593 13.3506 14.4195 13.8038 13.8797 13.8038L2.12025 13.8046C1.58044 13.8046 1.14063 13.3514 1.14063 12.7952L1.13985 6.79816C1.13985 6.24194 1.56014 5.78876 2.11947 5.78876H4.7795C5.0998 5.78876 5.33963 5.5207 5.33963 5.2116C5.33963 4.90169 5.0998 4.61353 4.7795 4.61353H2.12027C0.939881 4.61353 0 5.56177 0 6.79827V12.8153C0 14.0307 0.939781 15 2.12027 15H13.8797C15.0593 15 16 14.0317 16 12.8153L15.9992 6.79827C15.9992 5.56185 15.0594 4.61365 13.8789 4.61365Z" fill="#A6ADBB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

-53
View File
@@ -1,53 +0,0 @@
<script lang="ts">
import type {Snippet} from "svelte"
type Props = {
children: Snippet
root?: HTMLElement
initiallyVisible?: boolean
estimatedHeight?: number
}
const {children, root, initiallyVisible = false, estimatedHeight = 48}: Props = $props()
let visible = $state(initiallyVisible)
let height = $state(estimatedHeight)
let el: HTMLElement | undefined = $state()
let hasMeasured = false
$effect(() => {
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
visible = true
} else {
// Measure actual height before hiding content
if (el) {
const h = el.offsetHeight
if (h > 0) {
height = h
hasMeasured = true
}
}
if (hasMeasured) {
visible = false
}
}
},
{root: root || null, rootMargin: "1000px 0px"},
)
observer.observe(el)
return () => observer.disconnect()
})
</script>
<div bind:this={el}>
{#if visible}
{@render children()}
{:else}
<div style:height="{height}px"></div>
{/if}
</div>
+1 -1
View File
@@ -40,7 +40,7 @@
<Page>
<ContentSearch>
{#snippet input()}
<label class="row-2 input input-bordered">
<label class="row-2 input input-bordered w-full">
<Icon icon={Magnifier} />
<!-- svelte-ignore a11y_autofocus -->
<input
+2 -2
View File
@@ -58,14 +58,14 @@
<RelaySettingsItem
icon={Inbox}
title="Inbox Relays"
subtitle="Where you send your public notes. Be sure to select relays that will accept your notes, and which will let people who follow you read them."
subtitle="Where other people should send notes intended for you. Be sure to select relays that will accept notes that tag you."
relays={readRelayUrls}
addRelay={addReadRelay}
removeRelay={removeReadRelay} />
<RelaySettingsItem
icon={Plane}
title="Outbox Relays"
subtitle="Where other people should send notes intended for you. Be sure to select relays that will accept notes that tag you."
subtitle="Where you send your public notes. Be sure to select relays that will accept your notes, and which will let people who follow you read them."
relays={writeRelayUrls}
addRelay={addWriteRelay}
removeRelay={removeWriteRelay} />
+36 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import {onMount, tick} from "svelte"
import {flip} from "svelte/animate"
import {cubicOut} from "svelte/easing"
import {derived as _derived} from "svelte/store"
import {dec, insertAt, removeAt, sleep} from "@welshman/lib"
import type {RelayProfile} from "@welshman/util"
@@ -7,6 +9,7 @@
import {relays, createSearch} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import DragHandle from "@assets/icons/drag-handle.svg?dataurl"
import Widget from "@assets/icons/widget-4.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@@ -98,6 +101,8 @@
const onDragStart = (e: DragEvent, url: string) => {
draggedUrl = url
dragStartOrder = [...orderedSpaceUrls]
lastDragTarget = undefined
didDrop = false
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move"
@@ -105,15 +110,25 @@
}
}
const onDragOver = (e: DragEvent, targetUrl: string) => {
const onDragOver = (e: DragEvent) => {
e.preventDefault()
}
const onDragEnter = (e: DragEvent, targetUrl: string) => {
e.preventDefault()
if (lastDragTarget === targetUrl) return
lastDragTarget = targetUrl
reorderSpaceUrls(targetUrl)
}
const onDrop = (e: DragEvent, targetUrl: string) => {
e.preventDefault()
reorderSpaceUrls(targetUrl)
didDrop = true
draggedUrl = undefined
lastDragTarget = undefined
if (dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
void setSpaceMembershipOrder(orderedSpaceUrls).catch(console.error)
@@ -123,8 +138,14 @@
}
const onDragEnd = () => {
if (!didDrop && dragStartOrder && !isSameOrder(dragStartOrder, orderedSpaceUrls)) {
orderedSpaceUrls = dragStartOrder
}
draggedUrl = undefined
dragStartOrder = undefined
lastDragTarget = undefined
didDrop = false
}
$effect(() => {
@@ -143,6 +164,8 @@
let orderedSpaceUrls = $state<string[]>([])
let draggedUrl = $state<string | undefined>()
let dragStartOrder = $state<string[] | undefined>()
let lastDragTarget = $state<string | undefined>()
let didDrop = $state(false)
const openSearch = () => {
showSearch = true
@@ -247,17 +270,25 @@
<Divider>Your spaces</Divider>
{#each filteredUserUrls as url (url)}
<div
class:opacity-60={draggedUrl === url}
animate:flip={{duration: 300, easing: cubicOut}}
class="transition-opacity duration-200 {draggedUrl === url ? 'opacity-50' : ''}"
draggable="true"
role="listitem"
ondragstart={e => onDragStart(e, url)}
ondragover={e => onDragOver(e, url)}
ondragover={onDragOver}
ondragenter={e => onDragEnter(e, url)}
ondrop={e => onDrop(e, url)}
ondragend={onDragEnd}>
<Button
class="card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative"
class="group card2 bg-alt shadow-md transition-all hover:shadow-lg hover:dark:brightness-[1.1] w-full relative min-w-0"
onclick={() => openSpace(url)}>
<RelaySummary hideFavorites {url} />
<div class="flex w-full items-start gap-2">
<div
class="mt-4 flex cursor-grab p-1 text-base-content/30 transition-colors group-hover:text-base-content/60">
<Icon icon={DragHandle} />
</div>
<RelaySummary hideFavorites {url} />
</div>
{#if $notifications.has(makeSpacePath(url))}
<div class="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary"></div>
{/if}
+15 -18
View File
@@ -30,7 +30,6 @@
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import VirtualItem from "@lib/components/VirtualItem.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
import {canEnforceNip70, prependParent, publishDelete} from "@app/core/commands"
import {
@@ -486,23 +485,21 @@
{:else if type === "date"}
<Divider>{value}</Divider>
{:else if shouldVirtualize}
<VirtualItem root={element} initiallyVisible={i < 25}>
{@const event = value as TrustedEvent}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
</VirtualItem>
{@const event = value as TrustedEvent}
{#if event.kind === ROOM_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div class="cv">
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
{addSpaceBelow}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === ROOM_ADD_MEMBER}
+15 -18
View File
@@ -21,7 +21,6 @@
import SpaceSearch from "@app/components/SpaceSearch.svelte"
import RoomItem from "@app/components/RoomItem.svelte"
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
import VirtualItem from "@lib/components/VirtualItem.svelte"
import RoomCompose from "@app/components/RoomCompose.svelte"
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
@@ -324,23 +323,21 @@
{:else if type === "date"}
<Divider>{value}</Divider>
{:else if shouldVirtualize}
<VirtualItem root={element} initiallyVisible={i < 25}>
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent}
{addSpaceBelow} />
</div>
{/if}
</VirtualItem>
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<div>
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent}
{addSpaceBelow} />
</div>
{/if}
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}