Improve group membership detection

This commit is contained in:
Jon Staab
2025-05-28 16:46:41 -07:00
parent 72d85e5740
commit f7d11cf124
11 changed files with 223 additions and 118 deletions
+5 -5
View File
@@ -4243,8 +4243,8 @@ packages:
svelte:
optional: true
svelte@4.2.19:
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
svelte@4.2.20:
resolution: {integrity: sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==}
engines: {node: '>=16'}
svelte@5.25.10:
@@ -6442,7 +6442,7 @@ snapshots:
'@welshman/util': 0.3.0(typescript@5.8.3)
fuse.js: 7.1.0
idb: 8.0.2
svelte: 4.2.19
svelte: 4.2.20
throttle-debounce: 5.0.2
transitivePeerDependencies:
- nostr-signer-capacitor-plugin
@@ -6563,7 +6563,7 @@ snapshots:
'@welshman/lib': 0.3.0
'@welshman/relay': 0.3.0(typescript@5.8.3)
'@welshman/util': 0.3.0(typescript@5.8.3)
svelte: 4.2.19
svelte: 4.2.20
transitivePeerDependencies:
- typescript
@@ -9327,7 +9327,7 @@ snapshots:
optionalDependencies:
svelte: 5.25.10
svelte@4.2.19:
svelte@4.2.20:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0
+3 -20
View File
@@ -53,6 +53,7 @@ import {
tagEventForComment,
tagEventForQuote,
thunkIsComplete,
getThunkError,
} from "@welshman/app"
import type {Thunk} from "@welshman/app"
import {
@@ -83,21 +84,6 @@ export const getPubkeyPetname = (pubkey: string) => {
return display
}
export const getThunkError = (thunk: Thunk) =>
new Promise<string>(resolve => {
thunk.subscribe($thunk => {
for (const [relay, status] of Object.entries($thunk.status)) {
if (status === PublishStatus.Failure) {
resolve($thunk.details[relay])
}
}
if (thunkIsComplete($thunk)) {
resolve("")
}
})
})
export const prependParent = (parent: TrustedEvent | undefined, {content, tags}: EventContent) => {
if (parent) {
const nevent = nip19.neventEncode({
@@ -189,12 +175,9 @@ export const removeSpaceMembership = async (url: string) => {
return publishThunk({event, relays})
}
export const addRoomMembership = async (url: string, room: string, name: string) => {
export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: GROUPS})
const newTags = [
["r", url],
["group", room, url, name],
]
const newTags = [["r", url], ["group", room, url]]
const event = await addToListPublicly(list, ...newTags).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
+2 -2
View File
@@ -3,7 +3,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath} from "@app/routes"
import {deriveChannel, channelIsLocked} from "@app/state"
import {deriveChannel} from "@app/state"
import {notifications} from "@app/notifications"
interface Props {
@@ -23,7 +23,7 @@
href={path}
{replaceState}
notification={notify ? $notifications.has(path) : false}>
{#if channelIsLocked($channel)}
{#if $channel?.closed || $channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
+2 -3
View File
@@ -2,7 +2,7 @@
import {goto} from "$app/navigation"
import {randomId} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {deriveRelay, getThunkError} from "@welshman/app"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -11,7 +11,7 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {hasNip29, loadChannel} from "@app/state"
import {addRoomMembership, createRoom, editRoom, joinRoom, getThunkError} from "@app/commands"
import {createRoom, editRoom, joinRoom} from "@app/commands"
import {makeSpacePath} from "@app/routes"
import {pushToast} from "@app/toast"
@@ -43,7 +43,6 @@
await loadChannel(url, room)
addRoomMembership(url, room, name)
goto(makeSpacePath(url, room))
}
+39 -7
View File
@@ -34,6 +34,9 @@ import {
GROUPS,
THREAD,
COMMENT,
GROUP_JOIN,
GROUP_ADD_USER,
GROUP_REMOVE_USER,
getGroupTags,
getRelayTagValues,
getPubkeyTagValues,
@@ -43,6 +46,8 @@ import {
getListTags,
asDecryptedEvent,
normalizeRelayUrl,
getTag,
getTagValues,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
@@ -486,8 +491,8 @@ export type Channel = {
room: string
name: string
event: TrustedEvent
access: "public" | "private"
membership: "open" | "closed"
closed: boolean
private: boolean
picture?: string
about?: string
}
@@ -520,8 +525,8 @@ export const channels = derived(
room,
event,
name: meta.name || room,
access: meta.private ? "private" : "public",
membership: meta.closed ? "closed" : "open",
closed: Boolean(getTag("closed", event.tags)),
private: Boolean(getTag("private", event.tags)),
picture: meta.picture,
about: meta.about,
})
@@ -563,9 +568,6 @@ export const displayChannel = (url: string, room: string) =>
export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase()
export const channelIsLocked = (channel?: Channel) =>
channel?.access === "private" && channel?.membership === "closed"
// User stuff
export const userSettings = withGetter(
@@ -626,6 +628,36 @@ export const deriveOtherRooms = (url: string) =>
),
)
export enum MembershipStatus {
Initial,
Pending,
Granted,
}
export const deriveUserMembershipStatus = (url: string, room: string) =>
derived(
[pubkey, deriveEventsForUrl(url, [{kinds: [GROUP_JOIN, GROUP_ADD_USER, GROUP_REMOVE_USER], '#h': [room]}])],
([$pubkey, $events]) => {
let status = MembershipStatus.Initial
for (const event of $events) {
if (event.kind === GROUP_JOIN && event.pubkey === $pubkey) {
status = MembershipStatus.Pending
}
if (event.kind === GROUP_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) {
break
}
if (event.kind === GROUP_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) {
return MembershipStatus.Granted
}
}
return status
}
)
// Other utils
export const encodeRelay = (url: string) =>
+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 16.0909V11.0975C21 6.80891 21 4.6646 19.682 3.3323C18.364 2 16.2426 2 12 2C7.75736 2 5.63604 2 4.31802 3.3323C3 4.6646 3 6.80891 3 11.0975V16.0909C3 19.1875 3 20.7358 3.73411 21.4123C4.08421 21.735 4.52615 21.9377 4.99692 21.9915C5.98402 22.1045 7.13673 21.0849 9.44216 19.0458C10.4612 18.1445 10.9708 17.6938 11.5603 17.5751C11.8506 17.5166 12.1494 17.5166 12.4397 17.5751C13.0292 17.6938 13.5388 18.1445 14.5578 19.0458C16.8633 21.0849 18.016 22.1045 19.0031 21.9915C19.4739 21.9377 19.9158 21.735 20.2659 21.4123C21 20.7358 21 19.1875 21 16.0909Z" stroke="#1C274D" stroke-width="1.5"/>
<path d="M15 6H9" stroke="#1C274D" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 784 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.15316 5.40838C10.4198 3.13613 11.0531 2 12 2C12.9469 2 13.5802 3.13612 14.8468 5.40837L15.1745 5.99623C15.5345 6.64193 15.7144 6.96479 15.9951 7.17781C16.2757 7.39083 16.6251 7.4699 17.3241 7.62805L17.9605 7.77203C20.4201 8.32856 21.65 8.60682 21.9426 9.54773C22.2352 10.4886 21.3968 11.4691 19.7199 13.4299L19.2861 13.9372C18.8096 14.4944 18.5713 14.773 18.4641 15.1177C18.357 15.4624 18.393 15.8341 18.465 16.5776L18.5306 17.2544C18.7841 19.8706 18.9109 21.1787 18.1449 21.7602C17.3788 22.3417 16.2273 21.8115 13.9243 20.7512L13.3285 20.4768C12.6741 20.1755 12.3469 20.0248 12 20.0248C11.6531 20.0248 11.3259 20.1755 10.6715 20.4768L10.0757 20.7512C7.77268 21.8115 6.62118 22.3417 5.85515 21.7602C5.08912 21.1787 5.21588 19.8706 5.4694 17.2544L5.53498 16.5776C5.60703 15.8341 5.64305 15.4624 5.53586 15.1177C5.42868 14.773 5.19043 14.4944 4.71392 13.9372L4.2801 13.4299C2.60325 11.4691 1.76482 10.4886 2.05742 9.54773C2.35002 8.60682 3.57986 8.32856 6.03954 7.77203L6.67589 7.62805C7.37485 7.4699 7.72433 7.39083 8.00494 7.17781C8.28555 6.96479 8.46553 6.64194 8.82547 5.99623L9.15316 5.40838Z" stroke="#1C274C" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+4
View File
@@ -9,6 +9,7 @@
import {switcher} from "@welshman/lib"
import AddSquare from "@assets/icons/Add Square.svg?dataurl"
import ArrowsALogout2 from "@assets/icons/Arrows ALogout 2.svg?dataurl"
import Bookmark from "@assets/icons/Bookmark.svg?dataurl"
import Code2 from "@assets/icons/Code 2.svg?dataurl"
import Document from "@assets/icons/Document.svg?dataurl"
import Earth from "@assets/icons/Earth.svg?dataurl"
@@ -80,6 +81,7 @@
import SmileCircle from "@assets/icons/Smile Circle.svg?dataurl"
import SquareShareLine from "@assets/icons/Square Share Line.svg?dataurl"
import SortVertical from "@assets/icons/Sort Vertical.svg?dataurl"
import Star from "@assets/icons/Star.svg?dataurl"
import TrashBin2 from "@assets/icons/Trash Bin 2.svg?dataurl"
import UFO3 from "@assets/icons/UFO 3.svg?dataurl"
import UserHeart from "@assets/icons/User Heart.svg?dataurl"
@@ -104,6 +106,7 @@
const data = switcher(icon, {
"add-square": AddSquare,
"arrows-a-logout-2": ArrowsALogout2,
bookmark: Bookmark,
"code-2": Code2,
document: Document,
earth: Earth,
@@ -177,6 +180,7 @@
"ufo-3": UFO3,
"square-share-line": SquareShareLine,
"sort-vertical": SortVertical,
star: Star,
"user-heart": UserHeart,
"user-circle": UserCircle,
"user-rounded": UserRounded,
+1 -6
View File
@@ -17,7 +17,6 @@
MESSAGE,
INBOX_RELAYS,
DIRECT_MESSAGE,
GROUP_META,
MUTES,
FOLLOWS,
PROFILE,
@@ -174,11 +173,7 @@
limit: 10_000,
repository,
rankEvent: (e: TrustedEvent) => {
if (
[PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, INBOX_RELAYS, GROUP_META].includes(
e.kind,
)
) {
if ([PROFILE, FOLLOWS, MUTES, RELAYS, BLOSSOM_SERVERS, INBOX_RELAYS].includes(e.kind)) {
return 1
}
+4 -3
View File
@@ -20,7 +20,6 @@
import {
hasNip29,
decodeRelay,
channelIsLocked,
makeChannelId,
channelsById,
deriveUserRooms,
@@ -155,9 +154,10 @@
</Link>
{#each $userRooms as room (room)}
{@const roomPath = makeRoomPath(url, room)}
{@const channel = $channelsById.get(makeChannelId(url, room))}
<Link href={roomPath} class="btn btn-neutral relative">
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
{#if channel?.closed || channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
@@ -173,9 +173,10 @@
<Divider>Other Rooms</Divider>
<div class="grid grid-cols-3 gap-2">
{#each $otherRooms as room (room)}
{@const channel = $channelsById.get(makeChannelId(url, room))}
<Link href={makeRoomPath(url, room)} class="btn btn-neutral">
<div class="relative flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channelIsLocked($channelsById.get(makeChannelId(url, room)))}
{#if channel?.closed || channel?.private}
<Icon icon="lock" size={4} />
{:else}
<Icon icon="hashtag" />
+156 -72
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import cx from 'classnames'
import {readable} from "svelte/store"
import {onMount, onDestroy} from "svelte"
import {page} from "$app/stores"
import type {Readable} from "svelte/store"
import {now, formatTimestampAsDate} from "@welshman/lib"
import {request} from "@welshman/net"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {createEvent, MESSAGE, DELETE, REACTION} from "@welshman/util"
import {pubkey, publishThunk, deriveRelay} from "@welshman/app"
import {createEvent, MESSAGE, DELETE, REACTION, GROUP_ADD_USER, GROUP_REMOVE_USER} from "@welshman/util"
import {pubkey, publishThunk, deriveRelay, getThunkError, waitForThunkCompletion} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
@@ -20,12 +22,15 @@
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
userRoomsByUrl,
userSettingValues,
decodeRelay,
tagRoom,
userRoomsByUrl,
displayChannel,
getEventsForUrl,
deriveUserMembershipStatus,
deriveChannel,
MembershipStatus,
} from "@app/state"
import {setChecked, checked} from "@app/notifications"
import {
@@ -34,7 +39,6 @@
addRoomMembership,
removeRoomMembership,
prependParent,
getThunkError,
} from "@app/commands"
import {PROTECTED} from "@app/state"
import {makeFeed} from "@app/requests"
@@ -45,25 +49,43 @@
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay)
const channel = deriveChannel(url, room)
const filter = {kinds: [MESSAGE], "#h": [room]}
const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room))
const membershipStatus = deriveUserMembershipStatus(url, room)
const addFavorite = () => addRoomMembership(url, room)
const removeFavorite = () => removeRoomMembership(url, room)
const join = async () => {
joiningRoom = true
joining = true
const message = await getThunkError(joinRoom(url, room))
try {
const message = await getThunkError(joinRoom(url, room))
joiningRoom = false
if (message && !message.includes("already")) {
return pushToast({theme: "error", message})
if (message && !message.startsWith("duplicate:")) {
return pushToast({theme: "error", message})
} else {
// Restart the feed now that we're a member
start()
}
} finally {
joining = false
}
addRoomMembership(url, room, displayChannel(url, room))
}
const leave = () => {
leaveRoom(url, room)
removeRoomMembership(url, room)
const leave = async () => {
leaving = true
try {
const message = await getThunkError(leaveRoom(url, room))
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
}
} finally {
leaving = false
}
}
const replyTo = (event: TrustedEvent) => {
@@ -124,7 +146,8 @@
const scrollToBottom = () => element?.scrollTo({top: 0, behavior: "smooth"})
let joiningRoom = $state(false)
let joining = $state(false)
let leaving = $state(false)
let loadingEvents = $state(true)
let share = $state(popKey<TrustedEvent | undefined>("share"))
let parent: TrustedEvent | undefined = $state()
@@ -196,8 +219,10 @@
return elements
})
onMount(() => {
;({events, cleanup} = makeFeed({
const start = () => {
cleanup?.()
const feed = makeFeed({
element: element!,
relays: [url],
feedFilters: [filter],
@@ -206,7 +231,25 @@
onExhausted: () => {
loadingEvents = false
},
}))
})
events = feed.events
cleanup = feed.cleanup
}
onMount(() => {
const controller = new AbortController()
const req = request({
signal: controller.signal,
relays: [url],
filters: [{
kinds: [GROUP_ADD_USER, GROUP_REMOVE_USER],
'#p': [$pubkey!],
'#h': [room],
limit: 10,
}],
})
const observer = new ResizeObserver(() => {
if (dynamicPadding && chatCompose) {
@@ -216,8 +259,10 @@
observer.observe(chatCompose!)
observer.observe(dynamicPadding!)
start()
return () => {
controller.abort()
observer.unobserve(chatCompose!)
observer.unobserve(dynamicPadding!)
}
@@ -246,21 +291,12 @@
{/snippet}
{#snippet action()}
<div class="row-2">
{#if $userRoomsByUrl.get(url)?.has(room)}
<Button class="btn btn-neutral btn-sm" onclick={leave}>
<Icon icon="arrows-a-logout-2" />
Leave Room
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joiningRoom} onclick={join}>
{#if joiningRoom}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room
</Button>
{/if}
<Button
class="btn btn-neutral btn-sm tooltip tooltip-left"
data-tip={isFavorite ? "Remove Favorite" : "Add Favorite"}
onclick={isFavorite ? removeFavorite : addFavorite}>
<Icon size={4} icon="bookmark" class={cx({'text-primary': isFavorite})} />
</Button>
<MenuSpaceButton {url} />
</div>
{/snippet}
@@ -268,48 +304,96 @@
<PageContent bind:element onscroll={onScroll} class="flex flex-col-reverse pt-4">
<div bind:this={dynamicPadding}></div>
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
bind:this={newMessages}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center">
<p class="row-2">
You aren't currently a member of this room.
</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon="clock-circle" />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Join Room
</Button>
{/if}
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
{url}
{room}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
</div>
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
</div>
{:else}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
bind:this={newMessages}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px flex-grow bg-primary"></div>
<p class="rounded-full bg-primary px-2 py-1 text-primary-content">New Messages</p>
<div class="h-px flex-grow bg-primary"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
{url}
{room}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey} />
</div>
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingEvents}
<Spinner loading={loadingEvents}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if}
</PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
</div>
<ChannelCompose bind:this={compose} {onSubmit} {url} />
{#if $channel?.private && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $channel?.closed && $membershipStatus !== MembershipStatus.Granted}
<div class="flex flex-row items-center justify-between m-4 px-4 py-3 card bg-alt">
<p>Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Icon icon="clock-circle" />
Access Pending
</Button>
{:else}
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
{#if joining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon="login-2" />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<ChannelComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<ChannelComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
</div>
<ChannelCompose bind:this={compose} {onSubmit} {url} />
{/if}
</div>
{#if showScrollButton}