Add relay members list and room join/leave events

This commit is contained in:
Matthew Remmel
2025-10-15 12:04:17 -04:00
committed by hodlbod
parent 43cf91e877
commit a730384baf
22 changed files with 499 additions and 323 deletions
+2 -2
View File
@@ -13,7 +13,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {alerts, getMembershipUrls, userMembership} from "@app/core/state" import {alerts, userSpaceUrls} from "@app/core/state"
import {requestRelayClaim} from "@app/core/requests" import {requestRelayClaim} from "@app/core/requests"
import {createAlert} from "@app/core/commands" import {createAlert} from "@app/core/commands"
import {canSendPushNotifications} from "@app/util/push" import {canSendPushNotifications} from "@app/util/push"
@@ -174,7 +174,7 @@
{#snippet input()} {#snippet input()}
<select bind:value={url} class="select select-bordered"> <select bind:value={url} class="select select-bordered">
<option value="" disabled selected>Choose a space URL</option> <option value="" disabled selected>Choose a space URL</option>
{#each getMembershipUrls($userMembership) as url (url)} {#each $userSpaceUrls as url (url)}
<option value={url}>{displayRelayUrl(url)}</option> <option value={url}>{displayRelayUrl(url)}</option>
{/each} {/each}
</select> </select>
@@ -0,0 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> joined the room
</div>
{/each}
@@ -0,0 +1,18 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getPubkeyTagValues} from "@welshman/util"
import ProfileLink from "@app/components/ProfileLink.svelte"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
</script>
{#each getPubkeyTagValues(event.tags) as pubkey}
<div class="py-1 text-center text-xs opacity-75">
<ProfileLink unstyled class="text-primary" {url} {pubkey} /> left the room
</div>
{/each}
+5 -10
View File
@@ -39,9 +39,7 @@
import { import {
ENABLE_ZAPS, ENABLE_ZAPS,
MESSAGE_FILTER, MESSAGE_FILTER,
userRoomsByUrl, deriveSpaceMembers,
hasMembershipUrl,
memberships,
deriveEventsForUrl, deriveEventsForUrl,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
@@ -62,6 +60,7 @@
const calendarPath = makeSpacePath(url, "calendar") const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const members = deriveSpaceMembers(url)
const owner = $derived($relay?.profile?.pubkey) const owner = $derived($relay?.profile?.pubkey)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url))) const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
@@ -83,7 +82,7 @@
const showMembers = () => const showMembers = () =>
pushModal( pushModal(
ProfileList, ProfileList,
{url, pubkeys: members, title: `Members of`, subtitle: displayRelayUrl(url)}, {url, pubkeys: $members, title: `Members of`, subtitle: displayRelayUrl(url)},
{replaceState}, {replaceState},
) )
@@ -108,10 +107,6 @@
let replaceState = $state(false) let replaceState = $state(false)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const members = $derived(
$memberships.filter(l => hasMembershipUrl(l, url)).map(l => l.event.pubkey),
)
onMount(() => { onMount(() => {
replaceState = Boolean(element?.closest(".drawer")) replaceState = Boolean(element?.closest(".drawer"))
}) })
@@ -151,7 +146,7 @@
<li> <li>
<Button onclick={showMembers}> <Button onclick={showMembers}>
<Icon icon={UserRounded} /> <Icon icon={UserRounded} />
View Members ({members.length}) View Members ({$members.length})
</Button> </Button>
</li> </li>
{#if owner} {#if owner}
@@ -163,7 +158,7 @@
</li> </li>
{/if} {/if}
<li> <li>
{#if $userRoomsByUrl.has(url)} {#if $userRooms.includes(url)}
<Button onclick={leaveSpace} class="text-error"> <Button onclick={leaveSpace} class="text-error">
<Icon icon={Exit} /> <Icon icon={Exit} />
Leave Space Leave Space
+3 -3
View File
@@ -5,15 +5,15 @@
import Divider from "@lib/components/Divider.svelte" import Divider from "@lib/components/Divider.svelte"
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte" import MenuSpacesItem from "@app/components/MenuSpacesItem.svelte"
import {userRoomsByUrl, PLATFORM_RELAYS} from "@app/core/state" import {userSpaceUrls, PLATFORM_RELAYS} from "@app/core/state"
</script> </script>
<div class="column menu gap-2"> <div class="column menu gap-2">
{#each PLATFORM_RELAYS as url (url)} {#each PLATFORM_RELAYS as url (url)}
<MenuSpacesItem {url} /> <MenuSpacesItem {url} />
{:else} {:else}
{#if $userRoomsByUrl.size > 0} {#if $userSpaceUrls.length > 0}
{#each $userRoomsByUrl.keys() as url (url)} {#each $userSpaceUrls as url (url)}
<MenuSpacesItem {url} /> <MenuSpacesItem {url} />
{/each} {/each}
<Divider /> <Divider />
+5 -5
View File
@@ -13,7 +13,7 @@
import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte" import MenuOtherSpaces from "@app/components/MenuOtherSpaces.svelte"
import MenuSettings from "@app/components/MenuSettings.svelte" import MenuSettings from "@app/components/MenuSettings.svelte"
import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte" import PrimaryNavItemSpace from "@app/components/PrimaryNavItemSpace.svelte"
import {userRoomsByUrl, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state" import {userSpaceUrls, PLATFORM_RELAYS, PLATFORM_LOGO} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
@@ -31,7 +31,8 @@
const {children}: Props = $props() const {children}: Props = $props()
const showSpacesMenu = () => (spaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)) const showSpacesMenu = () =>
$userSpaceUrls.length > 0 ? pushModal(MenuSpaces) : pushModal(SpaceAdd)
const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls}) const showOtherSpacesMenu = () => pushModal(MenuOtherSpaces, {urls: secondarySpaceUrls})
@@ -50,9 +51,8 @@
const itemHeight = 56 const itemHeight = 56
const navPadding = 6 * itemHeight const navPadding = 6 * itemHeight
const itemLimit = $derived((windowHeight - navPadding) / itemHeight) const itemLimit = $derived((windowHeight - navPadding) / itemHeight)
const spaceUrls = $derived(Array.from($userRoomsByUrl.keys())) const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, $userSpaceUrls))
const [primarySpaceUrls, secondarySpaceUrls] = $derived(splitAt(itemLimit, spaceUrls)) const anySpaceNotifications = $derived($userSpaceUrls.some(hasNotification))
const anySpaceNotifications = $derived(spaceUrls.some(hasNotification))
const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification)) const otherSpaceNotifications = $derived(secondarySpaceUrls.some(hasNotification))
</script> </script>
+11 -7
View File
@@ -5,11 +5,15 @@
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {deriveEvents} from "@welshman/store" import {deriveEvents} from "@welshman/store"
import {formatTimestampRelative} from "@welshman/lib" import {formatTimestampRelative} from "@welshman/lib"
import {NOTE, ROOMS, COMMENT, getRelayTags, getListTags} from "@welshman/util" import {NOTE, ROOMS, COMMENT} from "@welshman/util"
import {repository, loadRelaySelections} from "@welshman/app" import {repository, loadRelaySelections} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import ProfileSpaces from "@app/components/ProfileSpaces.svelte" import ProfileSpaces from "@app/components/ProfileSpaces.svelte"
import {membershipsByPubkey, MESSAGE_KINDS} from "@app/core/state" import {
deriveGroupSelections,
getSpaceUrlsFromGroupSelections,
MESSAGE_KINDS,
} from "@app/core/state"
import {goToEvent} from "@app/util/routes" import {goToEvent} from "@app/util/routes"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
@@ -21,8 +25,8 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const filters: Filter[] = [{authors: [pubkey], limit: 1}] const filters: Filter[] = [{authors: [pubkey], limit: 1}]
const events = deriveEvents(repository, {filters}) const events = deriveEvents(repository, {filters})
const membership = $derived($membershipsByPubkey.get(pubkey)) const selections = deriveGroupSelections(pubkey)
const relays = $derived(getRelayTags(getListTags(membership))) const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
const viewEvent = () => goToEvent($events[0]!) const viewEvent = () => goToEvent($events[0]!)
@@ -49,10 +53,10 @@
Last active {formatTimestampRelative($events[0].created_at)} Last active {formatTimestampRelative($events[0].created_at)}
</Button> </Button>
{/if} {/if}
{#if relays.length > 0} {#if spaceUrls.length > 0}
<Button onclick={openSpaces} class="badge badge-neutral"> <Button onclick={openSpaces} class="badge badge-neutral">
{relays.length} {spaceUrls.length}
{relays.length === 1 ? "space" : "spaces"} {spaceUrls.length === 1 ? "space" : "spaces"}
</Button> </Button>
{/if} {/if}
</div> </div>
+2 -12
View File
@@ -19,13 +19,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {logout} from "@app/core/commands" import {logout} from "@app/core/commands"
import { import {INDEXER_RELAYS, PLATFORM_NAME, userSpaceUrls, userWriteRelays} from "@app/core/state"
INDEXER_RELAYS,
PLATFORM_NAME,
userMembership,
getMembershipUrls,
userWriteRelays,
} from "@app/core/state"
let progress: number | undefined = $state(undefined) let progress: number | undefined = $state(undefined)
let confirmText = $state("") let confirmText = $state("")
@@ -46,11 +40,7 @@
const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"})) const profileEvent = makeEvent(PROFILE, createProfile({name: "[deleted]"}))
const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]}) const vanishEvent = makeEvent(62, {tags: [["relay", "ALL_RELAYS"]]})
const denominator = chunks.length + 2 const denominator = chunks.length + 2
const relays = uniq([ const relays = uniq([...INDEXER_RELAYS, ...$userWriteRelays, ...$userSpaceUrls])
...INDEXER_RELAYS,
...$userWriteRelays,
...getMembershipUrls($userMembership),
])
let step = 0 let step = 0
+3 -2
View File
@@ -8,7 +8,7 @@
import SpaceAvatar from "@app/components/SpaceAvatar.svelte" import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import {makeSpacePath} from "@app/util/routes" import {makeSpacePath} from "@app/util/routes"
import {getMembershipUrls, membershipsByPubkey} from "@app/core/state" import {deriveGroupSelections, getSpaceUrlsFromGroupSelections} from "@app/core/state"
type Props = { type Props = {
pubkey: string pubkey: string
@@ -16,7 +16,8 @@
const {pubkey}: Props = $props() const {pubkey}: Props = $props()
const spaceUrls = $derived(getMembershipUrls($membershipsByPubkey.get(pubkey))) const selections = deriveGroupSelections(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupSelections($selections))
const back = () => history.back() const back = () => history.back()
</script> </script>
+6 -5
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {gt} from "@welshman/lib"
import {deriveRelay} from "@welshman/app" import {deriveRelay} from "@welshman/app"
import Ghost from "@assets/icons/ghost-smile.svg?dataurl" import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
import CheckCircle from "@assets/icons/check-circle.svg?dataurl" import CheckCircle from "@assets/icons/check-circle.svg?dataurl"
@@ -7,7 +6,7 @@
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {membersByUrl, userRoomsByUrl} from "@app/core/state" import {deriveSpaceMembers, deriveUserRooms} from "@app/core/state"
type Props = { type Props = {
url: string url: string
@@ -15,6 +14,8 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = deriveRelay(url) const relay = deriveRelay(url)
const rooms = deriveUserRooms(url)
const members = deriveSpaceMembers(url)
</script> </script>
<div class="col-4 text-left"> <div class="col-4 text-left">
@@ -31,7 +32,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if $userRoomsByUrl.has(url)} {#if $rooms.includes(url)}
<div <div
class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary" class="tooltip absolute -right-1 -top-1 h-5 w-5 rounded-full bg-primary"
data-tip="You are already a member of this space."> data-tip="You are already a member of this space.">
@@ -48,10 +49,10 @@
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} />
</div> </div>
{#if gt($membersByUrl.get(url)?.size, 0)} {#if $members.length > 0}
<div class="row-2 card2 card2-sm bg-alt"> <div class="row-2 card2 card2-sm bg-alt">
Members: Members:
<ProfileCircles pubkeys={Array.from($membersByUrl.get(url) || [])} /> <ProfileCircles pubkeys={$members} />
</div> </div>
{/if} {/if}
</div> </div>
+3 -3
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {sleep, nthEq} from "@welshman/lib" import {sleep, nthEq} from "@welshman/lib"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import {displayRelayUrl, AUTH_INVITE} from "@welshman/util" import {displayRelayUrl, RELAY_INVITE} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@@ -38,7 +38,7 @@
request({ request({
relays: [url], relays: [url],
autoClose: true, autoClose: true,
filters: [{kinds: [AUTH_INVITE]}], filters: [{kinds: [RELAY_INVITE]}],
}), }),
sleep(2000), sleep(2000),
]) ])
@@ -83,7 +83,7 @@
This invite link can be used by clicking "Add Space" and pasting it there. This invite link can be used by clicking "Add Space" and pasting it there.
{#if !claim} {#if !claim}
This space did not issue a claim for this link, so additional steps might be This space did not issue a claim for this link, so additional steps might be
required for people using this invite link. required.
{/if} {/if}
</p> </p>
{/snippet} {/snippet}
+11 -21
View File
@@ -35,7 +35,7 @@ import {
RELAYS, RELAYS,
FOLLOWS, FOLLOWS,
REACTION, REACTION,
AUTH_JOIN, RELAY_JOIN,
ROOMS, ROOMS,
COMMENT, COMMENT,
ALERT_EMAIL, ALERT_EMAIL,
@@ -97,15 +97,14 @@ import type {SettingsValues, Alert} from "@app/core/state"
import { import {
SETTINGS, SETTINGS,
PROTECTED, PROTECTED,
userMembership,
INDEXER_RELAYS, INDEXER_RELAYS,
NOTIFIER_PUBKEY, NOTIFIER_PUBKEY,
NOTIFIER_RELAY, NOTIFIER_RELAY,
DEFAULT_BLOSSOM_SERVERS, DEFAULT_BLOSSOM_SERVERS,
userRoomsByUrl, userSpaceUrls,
userSettingsValues, userSettingsValues,
userInboxRelays, userInboxRelays,
getMembershipUrls, userGroupSelections,
} from "@app/core/state" } from "@app/core/state"
import {loadAlertStatuses} from "@app/core/requests" import {loadAlertStatuses} from "@app/core/requests"
import {platform, platformName, getPushInfo} from "@app/util/push" import {platform, platformName, getPushInfo} from "@app/util/push"
@@ -175,7 +174,7 @@ export const broadcastUserData = async (relays: string[]) => {
// List updates // List updates
export const addSpaceMembership = async (url: string) => { export const addSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: ROOMS}) const list = get(userGroupSelections) || makeList({kind: ROOMS})
const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf) const event = await addToListPublicly(list, ["r", url]).reconcile(nip44EncryptToSelf)
const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -183,7 +182,7 @@ export const addSpaceMembership = async (url: string) => {
} }
export const removeSpaceMembership = async (url: string) => { export const removeSpaceMembership = async (url: string) => {
const list = get(userMembership) || makeList({kind: ROOMS}) const list = get(userGroupSelections) || makeList({kind: ROOMS})
const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url const pred = (t: string[]) => t[t[0] === "r" ? 1 : 2] === url
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -192,7 +191,7 @@ export const removeSpaceMembership = async (url: string) => {
} }
export const addRoomMembership = async (url: string, room: string) => { export const addRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS}) const list = get(userGroupSelections) || makeList({kind: ROOMS})
const newTags = [ const newTags = [
["r", url], ["r", url],
["group", room, url], ["group", room, url],
@@ -204,7 +203,7 @@ export const addRoomMembership = async (url: string, room: string) => {
} }
export const removeRoomMembership = async (url: string, room: string) => { export const removeRoomMembership = async (url: string, room: string) => {
const list = get(userMembership) || makeList({kind: ROOMS}) const list = get(userGroupSelections) || makeList({kind: ROOMS})
const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3)) const pred = (t: string[]) => equals(["group", room, url], t.slice(0, 3))
const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf) const event = await removeFromListByPredicate(list, pred).reconcile(nip44EncryptToSelf)
const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)]) const relays = uniq([url, ...Router.get().FromUser().getUrls(), ...getRelayTagValues(event.tags)])
@@ -226,12 +225,7 @@ export const setRelayPolicy = (url: string, read: boolean, write: boolean) => {
return publishThunk({ return publishThunk({
event: makeEvent(list.kind, {tags}), event: makeEvent(list.kind, {tags}),
relays: [ relays: [url, ...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)],
url,
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
}) })
} }
@@ -248,11 +242,7 @@ export const setInboxRelayPolicy = (url: string, enabled: boolean) => {
return publishThunk({ return publishThunk({
event: makeEvent(list.kind, {tags}), event: makeEvent(list.kind, {tags}),
relays: [ relays: [...INDEXER_RELAYS, ...Router.get().FromUser().getUrls(), ...get(userSpaceUrls)],
...INDEXER_RELAYS,
...Router.get().FromUser().getUrls(),
...userRoomsByUrl.get().keys(),
],
}) })
} }
} }
@@ -632,7 +622,7 @@ export type JoinRequestParams = {
} }
export const makeJoinRequest = (params: JoinRequestParams) => export const makeJoinRequest = (params: JoinRequestParams) =>
makeEvent(AUTH_JOIN, {tags: [["claim", params.claim]]}) makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
export const publishJoinRequest = (params: JoinRequestParams) => export const publishJoinRequest = (params: JoinRequestParams) =>
publishThunk({event: makeJoinRequest(params), relays: [params.url]}) publishThunk({event: makeJoinRequest(params), relays: [params.url]})
@@ -781,7 +771,7 @@ export const updateProfile = async ({
}) => { }) => {
const router = Router.get() const router = Router.get()
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile) const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
const scenarios = [router.FromRelays(getMembershipUrls(userMembership.get()))] const scenarios = [router.FromRelays(get(userSpaceUrls))]
if (shouldBroadcast) { if (shouldBroadcast) {
scenarios.push(router.FromUser(), router.Index()) scenarios.push(router.FromUser(), router.Index())
+4 -4
View File
@@ -14,7 +14,7 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import { import {
EVENT_TIME, EVENT_TIME,
AUTH_INVITE, RELAY_INVITE,
ALERT_EMAIL, ALERT_EMAIL,
ALERT_WEB, ALERT_WEB,
ALERT_IOS, ALERT_IOS,
@@ -29,7 +29,7 @@ import {
import type {TrustedEvent, Filter, List} from "@welshman/util" import type {TrustedEvent, Filter, List} from "@welshman/util"
import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds" import {feedFromFilters, makeRelayFeed, makeIntersectionFeed} from "@welshman/feeds"
import {load, request} from "@welshman/net" import {load, request} from "@welshman/net"
import {repository, makeFeedController, loadRelay} from "@welshman/app" import {repository, makeFeedController, loadRelay, tracker} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util" import {daysBetween} from "@lib/util"
import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state" import {NOTIFIER_RELAY, getEventsForUrl} from "@app/core/state"
@@ -93,7 +93,7 @@ export const makeFeed = ({
} }
for (const event of added) { for (const event of added) {
if (matchFilters(filters, event)) { if (matchFilters(filters, event) && tracker.getRelays(event.id).has(url)) {
insertEvent(event) insertEvent(event)
} }
} }
@@ -267,7 +267,7 @@ export const discoverRelays = (lists: List[]) =>
) )
export const requestRelayClaim = async (url: string) => { export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [AUTH_INVITE], limit: 1}] const filters = [{kinds: [RELAY_INVITE], limit: 1}]
const events = await load({filters, relays: [url]}) const events = await load({filters, relays: [url]})
if (events.length > 0) { if (events.length > 0) {
+300 -181
View File
@@ -4,7 +4,9 @@ import {get, derived, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import { import {
on, on,
spec,
call, call,
first,
assoc, assoc,
remove, remove,
uniqBy, uniqBy,
@@ -12,9 +14,7 @@ import {
sort, sort,
prop, prop,
uniq, uniq,
nth,
pushToMapKey, pushToMapKey,
nthEq,
shuffle, shuffle,
parseJson, parseJson,
fromPairs, fromPairs,
@@ -24,7 +24,6 @@ import {
groupBy, groupBy,
always, always,
tryCatch, tryCatch,
last,
} from "@welshman/lib" } from "@welshman/lib"
import type {Socket} from "@welshman/net" import type {Socket} from "@welshman/net"
import { import {
@@ -46,49 +45,56 @@ import {
} from "@welshman/store" } from "@welshman/store"
import {isKindFeed, findFeed} from "@welshman/feeds" import {isKindFeed, findFeed} from "@welshman/feeds"
import { import {
getIdFilters, ALERT_ANDROID,
WRAP, ALERT_EMAIL,
DELETE, ALERT_IOS,
ALERT_STATUS,
ALERT_WEB,
APP_DATA,
CLIENT_AUTH, CLIENT_AUTH,
AUTH_JOIN, COMMENT,
REACTION, DELETE,
ZAP_REQUEST,
ZAP_RESPONSE,
DIRECT_MESSAGE,
DIRECT_MESSAGE_FILE, DIRECT_MESSAGE_FILE,
ROOM_META, DIRECT_MESSAGE,
EVENT_TIME,
MESSAGE, MESSAGE,
REACTION,
RELAY_ADD_MEMBER,
RELAY_JOIN,
RELAY_LEAVE,
RELAY_MEMBERS,
RELAY_REMOVE_MEMBER,
REPORT,
ROOM_ADD_MEMBER,
ROOM_CREATE_PERMISSION,
ROOM_JOIN,
ROOM_LEAVE,
ROOM_MEMBERS,
ROOM_META,
ROOM_REMOVE_MEMBER,
ROOMS, ROOMS,
THREAD, THREAD,
COMMENT, WRAP,
REPORT,
ROOM_JOIN,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
ROOM_CREATE_PERMISSION,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
APP_DATA,
ZAP_GOAL, ZAP_GOAL,
EVENT_TIME, ZAP_REQUEST,
getGroupTags, ZAP_RESPONSE,
getRelayTagValues,
getPubkeyTagValues,
displayProfile,
readList,
getListTags,
asDecryptedEvent, asDecryptedEvent,
normalizeRelayUrl, displayProfile,
getGroupTags,
getIdFilters,
getListTags,
getPubkeyTagValues,
getRelaysFromList,
getRelayTagValues,
getTag, getTag,
getTagValue, getTagValue,
getTagValues, getTagValues,
verifyEvent, isRelayUrl,
makeEvent, makeEvent,
normalizeRelayUrl,
readList,
RelayMode, RelayMode,
getRelaysFromList, verifyEvent,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, PublishedList, List, Filter} from "@welshman/util" import type {TrustedEvent, PublishedList, List, Filter} from "@welshman/util"
import {decrypt} from "@welshman/signer" import {decrypt} from "@welshman/signer"
@@ -111,6 +117,9 @@ import {
publishThunk, publishThunk,
userRelaySelections, userRelaySelections,
userInboxRelaySelections, userInboxRelaySelections,
deriveRelay,
makeUserData,
makeUserLoader,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
@@ -158,7 +167,7 @@ export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const NIP46_PERMS = export const NIP46_PERMS =
"nip44_encrypt,nip44_decrypt," + "nip44_encrypt,nip44_decrypt," +
[CLIENT_AUTH, AUTH_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST] [CLIENT_AUTH, RELAY_JOIN, MESSAGE, THREAD, COMMENT, ROOMS, WRAP, REACTION, ZAP_REQUEST]
.map(k => `sign_event:${k}`) .map(k => `sign_event:${k}`)
.join(",") .join(",")
@@ -191,7 +200,7 @@ export const entityLink = (entity: string) => `https://coracle.social/${entity}`
export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) => export const pubkeyLink = (pubkey: string, relays = Router.get().FromPubkeys([pubkey]).getUrls()) =>
entityLink(nip19.nprofileEncode({pubkey, relays})) entityLink(nip19.nprofileEncode({pubkey, relays}))
export const defaultPubkeys = derived(userFollows, $userFollows => { export const bootstrapPubkeys = derived(userFollows, $userFollows => {
const appPubkeys = DEFAULT_PUBKEYS.split(",") const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollows))) const userPubkeys = shuffle(getPubkeyTagValues(getListTags($userFollows)))
@@ -221,29 +230,31 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
) )
} }
export const getUrlsForEvent = derived([trackerStore, thunks], ([$tracker, $thunks]) => { export const getUrlsForEvent = withGetter(
const getThunksByEventId = memoize(() => { derived([trackerStore, thunks], ([$tracker, $thunks]) => {
const thunksByEventId = new Map<string, Thunk[]>() const getThunksByEventId = memoize(() => {
const thunksByEventId = new Map<string, Thunk[]>()
for (const thunk of $thunks) { for (const thunk of $thunks) {
pushToMapKey(thunksByEventId, thunk.event.id, thunk) pushToMapKey(thunksByEventId, thunk.event.id, thunk)
}
return thunksByEventId
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.options.relays) {
urls.push(url)
} }
}
return uniq(urls) return thunksByEventId
} })
})
return (id: string) => {
const urls = Array.from($tracker.getRelays(id))
for (const thunk of getThunksByEventId().get(id) || []) {
for (const url of thunk.options.relays) {
urls.push(url)
}
}
return uniq(urls)
}
}),
)
export const getEventsForUrl = (url: string, filters: Filter[]) => { export const getEventsForUrl = (url: string, filters: Filter[]) => {
const ids = uniq([ const ids = uniq([
@@ -266,6 +277,11 @@ export const deriveEventsForUrl = (url: string, filters: Filter[]) =>
return repository.query(filters.map(assoc("ids", ids))) return repository.query(filters.map(assoc("ids", ids)))
}) })
export const deriveSignedEventsForUrl = (url: string, filters: Filter[]) =>
derived([deriveEventsForUrl(url, filters), deriveRelay(url)], ([$events, $relay]) =>
$relay?.profile ? $events.filter(spec({pubkey: $relay.profile.self})) : [],
)
// Context // Context
appContext.dufflepudUrl = DUFFLEPUD_URL appContext.dufflepudUrl = DUFFLEPUD_URL
@@ -295,6 +311,15 @@ export const MESSAGE_FILTER = {kinds: MESSAGE_KINDS}
export const COMMENT_FILTER = makeCommentFilter(MESSAGE_KINDS) export const COMMENT_FILTER = makeCommentFilter(MESSAGE_KINDS)
export const MEMBERSHIP_KINDS = [
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER,
]
export const MEMBERSHIP_FILTER = {kinds: MEMBERSHIP_KINDS}
// Settings // Settings
export const SETTINGS = "flotilla/settings" export const SETTINGS = "flotilla/settings"
@@ -348,6 +373,19 @@ export const {
load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}), load: makeOutboxLoader(APP_DATA, {"#d": [SETTINGS]}),
}) })
export const userSettings = makeUserData({
mapStore: settingsByPubkey,
loadItem: loadSettings,
})
export const loadUserSettings = makeUserLoader(loadSettings)
export const userSettingsValues = withGetter(
derived(userSettings, $s => $s?.values || defaultSettings),
)
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
// Relays sending events with empty signatures that the user has to choose to trust // Relays sending events with empty signatures that the user has to choose to trust
export const relaysPendingTrust = withGetter(writable<string[]>([])) export const relaysPendingTrust = withGetter(writable<string[]>([]))
@@ -428,64 +466,6 @@ export const alertStatuses = withGetter(
export const deriveAlertStatus = (address: string) => export const deriveAlertStatus = (address: string) =>
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address)) derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
// Membership
export const hasMembershipUrl = (list: List | undefined, url: string) =>
getListTags(list).some(t => {
if (t[0] === "r") return t[1] === url
if (t[0] === "group") return t[2] === url
return false
})
export const getMembershipUrls = (list?: List) => {
const tags = getListTags(list)
return sort(
uniq([...getRelayTagValues(tags), ...getGroupTags(tags).map(nth(2))]).map(url =>
normalizeRelayUrl(url),
),
)
}
export const getMembershipRooms = (list?: List) =>
getGroupTags(getListTags(list)).map(([_, room, url, name = ""]) => ({url, room, name}))
export const getMembershipRoomsByUrl = (url: string, list?: List) =>
sort(getGroupTags(getListTags(list)).filter(nthEq(2, url)).map(nth(1)))
export const memberships = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [ROOMS]}],
itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
export const {
indexStore: membershipsByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
} = collection({
name: "memberships",
store: memberships,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(ROOMS),
})
export const membersByUrl = derived(
[defaultPubkeys, membershipsByPubkey],
([$defaultPubkeys, $membershipsByPubkey]) => {
const $membersByUrl = new Map<string, Set<string>>()
for (const pubkey of $defaultPubkeys) {
for (const url of getMembershipUrls($membershipsByPubkey.get(pubkey))) {
addToMapKey($membersByUrl, url, pubkey)
}
}
return $membersByUrl
},
)
// Chats // Chats
export const chatMessages = deriveEvents(repository, { export const chatMessages = deriveEvents(repository, {
@@ -556,12 +536,10 @@ export const chatSearch = derived(chats, $chats =>
}), }),
) )
// Messages // Channels
export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]}) export const messages = deriveEvents(repository, {filters: [{kinds: [MESSAGE]}]})
// Channels
export type Channel = { export type Channel = {
id: string id: string
url: string url: string
@@ -645,98 +623,243 @@ export const displayChannel = (url: string, room: string) =>
export const roomComparator = (url: string) => (room: string) => export const roomComparator = (url: string) => (room: string) =>
displayChannel(url, room).toLowerCase() displayChannel(url, room).toLowerCase()
// User stuff // User space/room selections
export const userSettings = withGetter( export const groupSelections = deriveEventsMapped<PublishedList>(repository, {
derived([pubkey, settingsByPubkey], ([$pubkey, $settingsByPubkey]) => { filters: [{kinds: [ROOMS]}],
if (!$pubkey) return undefined itemToEvent: item => item.event,
eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)),
})
loadSettings($pubkey) export const {
indexStore: groupSelectionsByPubkey,
deriveItem: deriveGroupSelections,
loadItem: loadGroupSelections,
} = collection({
name: "groupSelections",
store: groupSelections,
getKey: list => list.event.pubkey,
load: makeOutboxLoader(ROOMS),
})
return $settingsByPubkey.get($pubkey) export const groupSelectionsPubkeysByUrl = derived(groupSelections, $groupSelections => {
}), const result = new Map<string, Set<string>>()
)
export const userSettingsValues = withGetter( for (const list of $groupSelections) {
derived(userSettings, $s => $s?.values || defaultSettings), const tags = getListTags(list)
)
export const getSetting = <T>(key: keyof Settings["values"]) => userSettingsValues.get()[key] as T
export const userMembership = withGetter(
derived([pubkey, membershipsByPubkey], ([$pubkey, $membershipsByPubkey]) => {
if (!$pubkey) return undefined
loadMembership($pubkey)
return $membershipsByPubkey.get($pubkey)
}),
)
export const userRoomsByUrl = withGetter(
derived([userMembership, channelsById], ([$userMembership, $channelsById]) => {
const tags = getListTags($userMembership)
const $userRoomsByUrl = new Map<string, Set<string>>()
for (const url of getRelayTagValues(tags)) { for (const url of getRelayTagValues(tags)) {
$userRoomsByUrl.set(normalizeRelayUrl(url), new Set()) addToMapKey(result, url, list.event.pubkey)
} }
for (const [_, room, url] of getGroupTags(tags)) { for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
addToMapKey(result, url, list.event.pubkey)
}
}
}
return result
})
export const getSpaceUrlsFromGroupSelections = ($groupSelections: List | undefined) => {
const tags = getListTags($groupSelections)
const urls = getRelayTagValues(tags)
for (const tag of getGroupTags(tags)) {
const url = tag[2] || ""
if (isRelayUrl(url)) {
urls.push(url)
}
}
return uniq(urls.map(normalizeRelayUrl))
}
export const getSpaceRoomsFromGroupSelections = (
url: string,
$groupSelections: List | undefined,
) => {
const rooms: string[] = []
for (const [_, room, relay] of getGroupTags(getListTags($groupSelections))) {
if (url === relay) {
rooms.push(room)
}
}
return sortBy(roomComparator(url), rooms)
}
export const userGroupSelections = makeUserData({
mapStore: groupSelectionsByPubkey,
loadItem: loadGroupSelections,
})
export const loadUserGroupSelections = makeUserLoader(loadGroupSelections)
export const userSpaceUrls = derived(userGroupSelections, getSpaceUrlsFromGroupSelections)
export const deriveUserRooms = (url: string) =>
derived([userGroupSelections, channelsById], ([$userGroupSelections, $channelsById]) => {
const rooms: string[] = []
for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
if ($channelsById.has(makeChannelId(url, room))) { if ($channelsById.has(makeChannelId(url, room))) {
addToMapKey($userRoomsByUrl, normalizeRelayUrl(url), room) rooms.push(room)
} }
} }
return $userRoomsByUrl return sortBy(roomComparator(url), rooms)
}), })
)
export const deriveUserRooms = (url: string) =>
derived(userRoomsByUrl, $userRoomsByUrl =>
sortBy(roomComparator(url), uniq(Array.from($userRoomsByUrl.get(url) || []))),
)
export const deriveOtherRooms = (url: string) => export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => derived([deriveUserRooms(url), channelsByUrl], ([$userRooms, $channelsByUrl]) => {
sortBy( const rooms: string[] = []
roomComparator(url),
($channelsByUrl.get(url) || []).filter(c => !$userRooms.includes(c.room)).map(c => c.room), for (const {room} of $channelsByUrl.get(url) || []) {
), if (!$userRooms.includes(room)) {
rooms.push(room)
}
}
return sortBy(roomComparator(url), rooms)
})
// Space/room memberships
export const deriveSpaceMembers = (url: string) =>
derived(
deriveSignedEventsForUrl(url, [
{kinds: [RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER, RELAY_MEMBERS]},
]),
$events => {
const membersEvent = $events.find(spec({kind: RELAY_MEMBERS}))
if (membersEvent) {
return getTagValues("member", membersEvent.tags)
}
const members = new Set()
for (const event of $events) {
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)
},
) )
export const deriveRoomMembers = (url: string, room: string) =>
derived(
deriveEventsForUrl(url, [
{kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ROOM_MEMBERS], "#h": [room]},
]),
$events => {
const membersEvent = $events.find(spec({kind: ROOM_MEMBERS}))
if (membersEvent) {
return getPubkeyTagValues(membersEvent.tags)
}
const members = new Set()
for (const event of $events) {
const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) {
for (const pubkey of pubkeys) {
members.add(pubkey)
}
}
if (event.kind === ROOM_REMOVE_MEMBER) {
for (const pubkey of pubkeys) {
members.delete(pubkey)
}
}
}
return Array.from(members)
},
)
// User membership status
export enum MembershipStatus { export enum MembershipStatus {
Initial, Initial,
Pending, Pending,
Granted, Granted,
} }
export const deriveUserMembershipStatus = (url: string, room: string) => export const deriveUserSpaceMembershipStatus = (url: string) =>
derived( derived(
[ [
pubkey, pubkey,
deriveEventsForUrl(url, [ deriveSpaceMembers(url),
{kinds: [ROOM_JOIN, ROOM_ADD_USER, ROOM_REMOVE_USER], "#h": [room]}, deriveEventsForUrl(url, [{kinds: [RELAY_JOIN, RELAY_LEAVE]}]),
]),
], ],
([$pubkey, $events]) => { ([$pubkey, $members, $events]) => {
let status = MembershipStatus.Initial const isMember = $members.includes($pubkey)
for (const event of $events) { for (const event of $events) {
if (event.kind === ROOM_JOIN && event.pubkey === $pubkey) { if (event.pubkey !== $pubkey) {
status = MembershipStatus.Pending continue
} }
if (event.kind === ROOM_REMOVE_USER && getTagValues("p", event.tags).includes($pubkey!)) { if (event.kind === RELAY_JOIN) {
break return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
} }
if (event.kind === ROOM_ADD_USER && getTagValues("p", event.tags).includes($pubkey!)) { if (event.kind === RELAY_LEAVE) {
return MembershipStatus.Granted return MembershipStatus.Initial
} }
} }
return status return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
},
)
export const deriveUserRoomMembershipStatus = (url: string, room: string) =>
derived(
[
pubkey,
deriveRoomMembers(url, room),
deriveEventsForUrl(url, [{kinds: [ROOM_JOIN, ROOM_LEAVE], "#h": [room]}]),
],
([$pubkey, $members, $events]) => {
const isMember = $members.includes($pubkey)
for (const event of $events) {
if (event.pubkey !== $pubkey) {
continue
}
if (event.kind === ROOM_JOIN) {
return isMember ? MembershipStatus.Granted : MembershipStatus.Pending
}
if (event.kind === ROOM_LEAVE) {
return MembershipStatus.Initial
}
}
return isMember ? MembershipStatus.Granted : MembershipStatus.Initial
}, },
) )
@@ -744,13 +867,9 @@ export const deriveUserCanCreateRoom = (url: string) =>
derived( derived(
[pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}])], [pubkey, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}])],
([$pubkey, $events]) => { ([$pubkey, $events]) => {
const latest = last($events) const event = first($events)
if (!latest) { return event ? getPubkeyTagValues(event.tags).includes($pubkey!) : true
return true
}
return getTagValues("p", latest.tags).includes($pubkey!)
}, },
) )
@@ -853,7 +972,7 @@ export const deriveRelayAuthError = (url: string, claim = "") => {
// Attempt to join the relay // Attempt to join the relay
const thunk = publishThunk({ const thunk = publishThunk({
event: makeEvent(AUTH_JOIN, {tags: [["claim", claim]]}), event: makeEvent(RELAY_JOIN, {tags: [["claim", claim]]}),
relays: [url], relays: [url],
}) })
+18 -20
View File
@@ -19,10 +19,9 @@ import {
getRelayTagValues, getRelayTagValues,
WRAP, WRAP,
ROOM_META, ROOM_META,
ROOM_ADD_USER, ROOM_ADD_MEMBER,
ROOM_REMOVE_USER, ROOM_REMOVE_MEMBER,
isSignedEvent, isSignedEvent,
normalizeRelayUrl,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, TrustedEvent} from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util"
import {request, load, pull} from "@welshman/net" import {request, load, pull} from "@welshman/net"
@@ -45,13 +44,14 @@ import {
import { import {
MESSAGE_FILTER, MESSAGE_FILTER,
COMMENT_FILTER, COMMENT_FILTER,
MEMBERSHIP_FILTER,
INDEXER_RELAYS, INDEXER_RELAYS,
REACTION_KINDS, REACTION_KINDS,
loadSettings, loadSettings,
userMembership, loadGroupSelections,
defaultPubkeys, userSpaceUrls,
bootstrapPubkeys,
decodeRelay, decodeRelay,
loadMembership,
getUrlsForEvent, getUrlsForEvent,
} from "@app/core/state" } from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests" import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
@@ -102,15 +102,15 @@ const syncRelays = () => {
} }
}) })
const unsubscribeMembership = userMembership.subscribe($l => { const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
for (const url of getRelayTagValues(getListTags($l))) { for (const url of urls) {
loadRelay(url) loadRelay(url)
} }
}) })
return () => { return () => {
unsubscribePage() unsubscribePage()
unsubscribeMembership() unsubscribeSpaceUrls()
} }
} }
@@ -131,7 +131,7 @@ const syncUserData = () => {
loadAlertStatuses($pubkey) loadAlertStatuses($pubkey)
loadBlossomServers($pubkey) loadBlossomServers($pubkey)
loadFollows($pubkey) loadFollows($pubkey)
loadMembership($pubkey) loadGroupSelections($pubkey)
loadMutes($pubkey) loadMutes($pubkey)
loadProfile($pubkey) loadProfile($pubkey)
loadSettings($pubkey) loadSettings($pubkey)
@@ -139,13 +139,13 @@ const syncUserData = () => {
}) })
const unsubscribeFollows = userFollows.subscribe(async $l => { const unsubscribeFollows = userFollows.subscribe(async $l => {
for (const pubkeys of chunk(10, get(defaultPubkeys))) { for (const pubkeys of chunk(10, get(bootstrapPubkeys))) {
// This isn't urgent, avoid clogging other stuff up // This isn't urgent, avoid clogging other stuff up
await sleep(1000) await sleep(1000)
for (const pk of pubkeys) { for (const pk of pubkeys) {
loadRelaySelections(pk).then(() => { loadRelaySelections(pk).then(() => {
loadMembership(pk) loadGroupSelections(pk)
loadProfile(pk) loadProfile(pk)
loadFollows(pk) loadFollows(pk)
loadMutes(pk) loadMutes(pk)
@@ -177,14 +177,14 @@ const syncMembership = (url: string) => {
pullConservatively({ pullConservatively({
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: [MESSAGE_FILTER, COMMENT_FILTER].map(assoc("since", ago(MONTH))), filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", ago(MONTH))),
}) })
// Listen for new events // Listen for new events
request({ request({
relays: [url], relays: [url],
signal: controller.signal, signal: controller.signal,
filters: [MESSAGE_FILTER, COMMENT_FILTER].map(assoc("since", now())), filters: [MESSAGE_FILTER, COMMENT_FILTER, MEMBERSHIP_FILTER].map(assoc("since", now())),
}) })
return () => controller.abort() return () => controller.abort()
@@ -193,9 +193,7 @@ const syncMembership = (url: string) => {
const syncMemberships = () => { const syncMemberships = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribeMembership = userMembership.subscribe($l => { const unsubscribeSpaceUrls = userSpaceUrls.subscribe(urls => {
const urls = getRelayTagValues(getListTags($l)).map(normalizeRelayUrl)
// stop syncing removed spaces // stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) { if (!urls.includes(url)) {
@@ -214,7 +212,7 @@ const syncMemberships = () => {
return () => { return () => {
Array.from(unsubscribersByUrl.values()).forEach(call) Array.from(unsubscribersByUrl.values()).forEach(call)
unsubscribeMembership() unsubscribeSpaceUrls()
} }
} }
@@ -231,7 +229,7 @@ const syncSpace = (url: string) => {
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{ {
kinds: [ROOM_ADD_USER, ROOM_REMOVE_USER], kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER],
"#p": [$pubkey], "#p": [$pubkey],
}, },
], ],
@@ -244,7 +242,7 @@ const syncSpace = (url: string) => {
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{ {
kinds: [ROOM_ADD_USER, ROOM_REMOVE_USER, ...REACTION_KINDS], kinds: [ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ...REACTION_KINDS],
since: now(), since: now(),
}, },
], ],
+15 -5
View File
@@ -17,9 +17,11 @@ import {
chats, chats,
hasNip29, hasNip29,
getUrlsForEvent, getUrlsForEvent,
userRoomsByUrl,
repositoryStore, repositoryStore,
userSettingsValues, userSettingsValues,
userGroupSelections,
getSpaceUrlsFromGroupSelections,
getSpaceRoomsFromGroupSelections,
} from "@app/core/state" } from "@app/core/state"
import {preferencesStorageProvider} from "@src/lib/storage" import {preferencesStorageProvider} from "@src/lib/storage"
import {Badge} from "@capawesome/capacitor-badge" import {Badge} from "@capawesome/capacitor-badge"
@@ -42,11 +44,19 @@ export const notifications = derived(
throttled( throttled(
1000, 1000,
derived( derived(
[pubkey, checked, chats, userRoomsByUrl, repositoryStore, getUrlsForEvent, relaysByUrl], [pubkey, checked, chats, userGroupSelections, repositoryStore, getUrlsForEvent, relaysByUrl],
identity, identity,
), ),
), ),
([$pubkey, $checked, $chats, $userRoomsByUrl, $repository, $getUrlsForEvent, $relaysByUrl]) => { ([
$pubkey,
$checked,
$chats,
$userGroupSelections,
$repository,
$getUrlsForEvent,
$relaysByUrl,
]) => {
const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => { const hasNotification = (path: string, latestEvent: TrustedEvent | undefined) => {
if (!latestEvent || latestEvent.pubkey === $pubkey) { if (!latestEvent || latestEvent.pubkey === $pubkey) {
return false return false
@@ -85,7 +95,7 @@ export const notifications = derived(
const allMessages = $repository.query([{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}]) const allMessages = $repository.query([{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}])
for (const [url, rooms] of $userRoomsByUrl.entries()) { for (const url of getSpaceUrlsFromGroupSelections($userGroupSelections)) {
const spacePath = makeSpacePath(url) const spacePath = makeSpacePath(url)
const spacePathMobile = spacePath + ":mobile" const spacePathMobile = spacePath + ":mobile"
const goalPath = makeGoalPath(url) const goalPath = makeGoalPath(url)
@@ -161,7 +171,7 @@ export const notifications = derived(
} }
if (hasNip29($relaysByUrl.get(url))) { if (hasNip29($relaysByUrl.get(url))) {
for (const room of rooms) { for (const room of getSpaceRoomsFromGroupSelections(url, $userGroupSelections)) {
const roomPath = makeRoomPath(url, room) const roomPath = makeRoomPath(url, room)
const latestEvent = allMessages.find( const latestEvent = allMessages.find(
e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])), e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])),
+3 -2
View File
@@ -1,4 +1,5 @@
import type {Page} from "@sveltejs/kit" import type {Page} from "@sveltejs/kit"
import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib" import {nthEq, sleep} from "@welshman/lib"
@@ -21,7 +22,7 @@ import {
entityLink, entityLink,
decodeRelay, decodeRelay,
encodeRelay, encodeRelay,
userRoomsByUrl, userSpaceUrls,
hasNip29, hasNip29,
ROOM, ROOM,
} from "@app/core/state" } from "@app/core/state"
@@ -66,7 +67,7 @@ export const makeCalendarPath = (url: string, eventId?: string) =>
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1] export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => { export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = Array.from(userRoomsByUrl.get().keys()) const urls = get(userSpaceUrls)
switch (getPrimaryNavItem($page)) { switch (getPrimaryNavItem($page)) {
case "discover": case "discover":
+12 -9
View File
@@ -6,7 +6,7 @@
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import type {Relay} from "@welshman/app" import type {Relay} from "@welshman/app"
import {relays, createSearch, loadRelay, loadRelaySelections} from "@welshman/app" import {relays, createSearch, loadRelay} from "@welshman/app"
import {createScroller} from "@lib/html" import {createScroller} from "@lib/html"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import QrCode from "@assets/icons/qr-code.svg?dataurl" import QrCode from "@assets/icons/qr-code.svg?dataurl"
@@ -23,7 +23,12 @@
import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte" import SpaceInviteAccept from "@app/components/SpaceInviteAccept.svelte"
import RelaySummary from "@app/components/RelaySummary.svelte" import RelaySummary from "@app/components/RelaySummary.svelte"
import SpaceCheck from "@app/components/SpaceCheck.svelte" import SpaceCheck from "@app/components/SpaceCheck.svelte"
import {getMembershipUrls, loadMembership, defaultPubkeys, membersByUrl} from "@app/core/state" import {
bootstrapPubkeys,
loadGroupSelections,
getSpaceUrlsFromGroupSelections,
groupSelectionsPubkeysByUrl,
} from "@app/core/state"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
const openMenu = () => pushModal(SpaceAdd) const openMenu = () => pushModal(SpaceAdd)
@@ -45,11 +50,9 @@
filters: [{kinds: [ROOMS]}], filters: [{kinds: [ROOMS]}],
relays: Router.get().Index().getUrls(), relays: Router.get().Index().getUrls(),
}), }),
...$defaultPubkeys.map(async pubkey => { ...$bootstrapPubkeys.map(async pubkey => {
await loadRelaySelections(pubkey) const list = await loadGroupSelections(pubkey)
const urls = getSpaceUrlsFromGroupSelections(list)
const membership = await loadMembership(pubkey)
const urls = getMembershipUrls(membership)
await Promise.all(urls.map(url => loadRelay(url))) await Promise.all(urls.map(url => loadRelay(url)))
}), }),
@@ -57,13 +60,13 @@
const relaySearch = $derived( const relaySearch = $derived(
createSearch( createSearch(
$relays.filter(r => $membersByUrl.has(r.url) && r.url !== termUrl), $relays.filter(r => $groupSelectionsPubkeysByUrl.has(r.url) && r.url !== termUrl),
{ {
getValue: (relay: Relay) => relay.url, getValue: (relay: Relay) => relay.url,
sortFn: ({score, item}) => { sortFn: ({score, item}) => {
if (score && score > 0.1) return -score! if (score && score > 0.1) return -score!
const wotScore = $membersByUrl.get(item.url)?.size || 0 const wotScore = $groupSelectionsPubkeysByUrl.get(item.url)!.size
return score ? dec(score) * wotScore : -wotScore return score ? dec(score) * wotScore : -wotScore
}, },
+2 -2
View File
@@ -7,13 +7,13 @@
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte"
import PeopleItem from "@app/components/PeopleItem.svelte" import PeopleItem from "@app/components/PeopleItem.svelte"
import {defaultPubkeys} from "@app/core/state" import {bootstrapPubkeys} from "@app/core/state"
let term = $state("") let term = $state("")
let limit = $state(10) let limit = $state(10)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const pubkeys = $derived(term ? $profileSearch.searchValues(term) : $defaultPubkeys) const pubkeys = $derived(term ? $profileSearch.searchValues(term) : $bootstrapPubkeys)
onMount(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
+32 -16
View File
@@ -7,7 +7,13 @@
import type {MakeNonOptional} from "@welshman/lib" import type {MakeNonOptional} from "@welshman/lib"
import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib" import {now, formatTimestampAsDate, ago, MINUTE} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, makeRoomMeta, MESSAGE} from "@welshman/util" import {
makeEvent,
makeRoomMeta,
MESSAGE,
ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER,
} from "@welshman/util"
import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app" import {pubkey, publishThunk, waitForThunkError, joinRoom, leaveRoom} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition" import {slide, fade, fly} from "@lib/transition"
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
@@ -26,13 +32,16 @@
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte" import ChannelName from "@app/components/ChannelName.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte" import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte"
import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte" import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte" import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import { import {
userRoomsByUrl, deriveUserRooms,
userSettingsValues, userSettingsValues,
decodeRelay, decodeRelay,
deriveUserMembershipStatus, deriveUserRoomMembershipStatus,
deriveChannel, deriveChannel,
MembershipStatus, MembershipStatus,
PROTECTED, PROTECTED,
@@ -49,16 +58,16 @@
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
const {room, relay} = $page.params as MakeNonOptional<typeof $page.params> const {room, relay} = $page.params as MakeNonOptional<typeof $page.params>
const mounted = now() const mounted = now()
const lastChecked = $checked[$page.url.pathname] const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay(relay) const url = decodeRelay(relay)
const channel = deriveChannel(url, room) const channel = deriveChannel(url, room)
const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room))
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserMembershipStatus(url, room) const userRooms = deriveUserRooms(url)
const isFavorite = $derived($userRooms.includes(room))
const membershipStatus = deriveUserRoomMembershipStatus(url, room)
const addFavorite = () => addRoomMembership(url, room) const addFavorite = () => addRoomMembership(url, room)
@@ -256,7 +265,7 @@
const feed = makeFeed({ const feed = makeFeed({
url, url,
element: element!, element: element!,
filters: [{kinds: MESSAGE_KINDS, "#h": [room]}], filters: [{kinds: [...MESSAGE_KINDS, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER], "#h": [room]}],
onExhausted: () => { onExhausted: () => {
loadingEvents = false loadingEvents = false
}, },
@@ -398,15 +407,22 @@
{:else if type === "date"} {:else if type === "date"}
<Divider>{value}</Divider> <Divider>{value}</Divider>
{:else} {:else}
<div in:slide class:-mt-1={!showPubkey}> {@const event = $state.snapshot(value as TrustedEvent)}
<ChannelItem {#if event.kind === ROOM_ADD_MEMBER}
{url} <ChannelItemAddMember {url} {event} />
{replyTo} {:else if event.kind === ROOM_REMOVE_MEMBER}
event={$state.snapshot(value as TrustedEvent)} <ChannelItemRemoveMember {url} {event} />
{showPubkey} {:else}
canEdit={canEditEvent} <div in:slide class:-mt-1={!showPubkey}>
onEdit={onEditEvent} /> <ChannelItem
</div> {url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if} {/if}
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20"> <p class="flex h-10 items-center justify-center py-20">
+22 -14
View File
@@ -5,9 +5,9 @@
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib" import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE} from "@welshman/util" import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app" import {pubkey, publishThunk} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition" import {fade, fly} from "@lib/transition"
import ChatRound from "@assets/icons/chat-round.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@@ -19,15 +19,17 @@
import ThunkToast from "@app/components/ThunkToast.svelte" import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte" import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte" import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelItemAddMember from "@src/app/components/ChannelItemAddMember.svelte"
import ChannelItemRemoveMember from "@src/app/components/ChannelItemRemoveMember.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte" import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte" import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {userSettingsValues, decodeRelay, MESSAGE_FILTER, PROTECTED} from "@app/core/state" import {userSettingsValues, decodeRelay, PROTECTED, MESSAGE_KINDS} from "@app/core/state"
import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands" import {prependParent, canEnforceNip70, publishDelete} from "@app/core/commands"
import {setChecked, checked} from "@app/util/notifications" import {setChecked, checked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import ChannelComposeEdit from "@src/app/components/ChannelComposeEdit.svelte"
const mounted = now() const mounted = now()
const lastChecked = $checked[$page.url.pathname] const lastChecked = $checked[$page.url.pathname]
@@ -218,7 +220,7 @@
const feed = makeFeed({ const feed = makeFeed({
url, url,
element: element!, element: element!,
filters: [MESSAGE_FILTER], filters: [{kinds: [...MESSAGE_KINDS, RELAY_ADD_MEMBER, RELAY_REMOVE_MEMBER]}],
onExhausted: () => { onExhausted: () => {
loadingEvents = false loadingEvents = false
}, },
@@ -271,15 +273,21 @@
<Divider>{value}</Divider> <Divider>{value}</Divider>
{:else} {:else}
{@const event = $state.snapshot(value as TrustedEvent)} {@const event = $state.snapshot(value as TrustedEvent)}
<div in:slide class:-mt-1={!showPubkey}> {#if event.kind === RELAY_ADD_MEMBER}
<ChannelItem <ChannelItemAddMember {url} {event} />
{url} {:else if event.kind === RELAY_REMOVE_MEMBER}
{event} <ChannelItemRemoveMember {url} {event} />
{replyTo} {:else}
{showPubkey} <div class:-mt-1={!showPubkey}>
canEdit={canEditEvent} <ChannelItem
onEdit={onEditEvent} /> {url}
</div> {event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
</div>
{/if}
{/if} {/if}
{/each} {/each}
<p class="flex h-10 items-center justify-center py-20"> <p class="flex h-10 items-center justify-center py-20">
+4
View File
@@ -9,6 +9,10 @@ config({path: ".env.template"})
export default { export default {
content: ["./src/**/*.{html,js,svelte,ts}"], content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: ['selector', '[data-theme="dark"]'], darkMode: ['selector', '[data-theme="dark"]'],
safelist: [
'bg-success',
'bg-warning',
],
theme: { theme: {
extend: {}, extend: {},
zIndex: { zIndex: {