Compare commits

...

5 Commits

Author SHA1 Message Date
Jon Staab
c80988882b Pull group meta directly from events 2024-06-18 17:20:12 -07:00
Jon Staab
3e3d8ab618 Publish group meta to new relays as well as old 2024-06-18 14:32:30 -07:00
Jon Staab
058a99b372 Fix group loading 2024-06-18 11:03:02 -07:00
Jon Staab
a23f71d307 Update changelog 2024-06-17 16:00:14 -07:00
Jon Staab
1dccb3e95c fix some community/calendar bugs 2024-06-17 15:42:17 -07:00
36 changed files with 369 additions and 415 deletions

View File

@ -7,6 +7,9 @@
- [x] Add `k` tag to deletions - [x] Add `k` tag to deletions
- [x] Allow users to choose where to publish their profile when using a white-labeled instance - [x] Allow users to choose where to publish their profile when using a white-labeled instance
- [x] Add "open with" link populated by nip 89 handlers - [x] Add "open with" link populated by nip 89 handlers
- [x] Fix several community and calendar related bugs
- [x] Add reports using tagr-bot
- [x] Open links to coracle in same tab
# 0.4.6 # 0.4.6

View File

@ -125,7 +125,7 @@
class="staatliches px-8 text-tinted-400 hover:text-tinted-100" class="staatliches px-8 text-tinted-400 hover:text-tinted-100"
on:click={() => setSubMenu("settings")}>Settings</Anchor> on:click={() => setSubMenu("settings")}>Settings</Anchor>
<div class="staatliches block flex h-8 gap-2 px-8 text-tinted-500"> <div class="staatliches block flex h-8 gap-2 px-8 text-tinted-500">
<Anchor external class="hover:text-tinted-100" href="/about">About</Anchor> / <Anchor class="hover:text-tinted-100" href="/about">About</Anchor> /
<Anchor external class="hover:text-tinted-100" href="/terms.html">Terms</Anchor> / <Anchor external class="hover:text-tinted-100" href="/terms.html">Terms</Anchor> /
<Anchor external class="hover:text-tinted-100" href="/privacy.html">Privacy</Anchor> <Anchor external class="hover:text-tinted-100" href="/privacy.html">Privacy</Anchor>
</div> </div>

View File

@ -1,31 +1,41 @@
<script lang="ts"> <script lang="ts">
import {fromPairs} from "ramda"
import {batch} from "hurdak" import {batch} from "hurdak"
import {onMount, onDestroy} from "svelte" import {onMount} from "svelte"
import {writable} from "@welshman/lib" import {fromPairs} from "@welshman/lib"
import {getAddress, getReplyFilters} from "@welshman/util" import {getAddress, getReplyFilters} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {feedFromFilter} from "@welshman/feeds"
import Calendar from "@event-calendar/core" import Calendar from "@event-calendar/core"
import DayGrid from "@event-calendar/day-grid" import DayGrid from "@event-calendar/day-grid"
import Interaction from "@event-calendar/interaction" import Interaction from "@event-calendar/interaction"
import {secondsToDate} from "src/util/misc" import {secondsToDate} from "src/util/misc"
import {themeColors} from "src/partials/state" import {themeColors} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import { import {hints, load, pubkey, canSign, loadAll, deriveEventsMapped} from "src/engine"
hints,
load,
pubkey,
canSign,
repository,
subscribe,
feedLoader,
getFilterSelections,
} from "src/engine"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
export let feed export let filter
export let group = null export let group = null
const calendarEvents = deriveEventsMapped({
filters: [filter],
itemToEvent: (item: any) => item.event,
eventToItem: (event: TrustedEvent) => {
const meta = fromPairs(event.tags)
const isOwn = event.pubkey === $pubkey
return {
event,
editable: isOwn,
id: getAddress(event),
title: meta.title || meta.name, // Backwards compat with a bug
start: secondsToDate(meta.start),
end: secondsToDate(meta.end),
backgroundColor: $themeColors[isOwn ? "accent" : "neutral-100"],
}
},
})
const createEvent = () => router.at("notes/create").qp({type: "calendar_event", group}).open() const createEvent = () => router.at("notes/create").qp({type: "calendar_event", group}).open()
const getEventContent = ({event}) => event.title const getEventContent = ({event}) => event.title
@ -45,55 +55,17 @@
const onEventClick = ({event: calendarEvent}) => router.at("events").of(calendarEvent.id).open() const onEventClick = ({event: calendarEvent}) => router.at("events").of(calendarEvent.id).open()
const events = writable(new Map()) onMount(() => {
loadAll(feedFromFilter(filter), {
const onEvent = batch(300, (chunk: TrustedEvent[]) => { // Load deletes for these events
events.update($events => { onEvent: batch(300, (chunk: TrustedEvent[]) => {
for (const e of chunk) { load({
const addr = getAddress(e) relays: hints.merge(chunk.map(e => hints.EventChildren(e))).getUrls(),
const dup = $events.get(addr) filters: getReplyFilters(chunk, {kinds: [5]}),
})
// Make sure we have the latest version of every event }),
$events.set(addr, dup?.created_at > e.created_at ? dup : e)
}
return $events
})
// Load deletes for these events
load({
relays: hints.merge(chunk.map(e => hints.EventChildren(e))).getUrls(),
filters: getReplyFilters(chunk, {kinds: [5]}),
}) })
}) })
let subs = []
onMount(async () => {
const [{filters}] = await feedLoader.compiler.compile(feed)
subs = getFilterSelections(filters).map(({relay, filters}) =>
subscribe({relays: [relay], filters, onEvent}),
)
})
onDestroy(() => subs.map(sub => sub.close()))
$: calendarEvents = Array.from($events.values())
.filter(e => !repository.isDeleted(e))
.map(e => {
const meta = fromPairs(e.tags)
const isOwn = e.pubkey === $pubkey
return {
editable: isOwn,
id: getAddress(e),
title: meta.title || meta.name, // Backwards compat with a bug
start: secondsToDate(meta.start),
end: secondsToDate(meta.end),
backgroundColor: $themeColors[isOwn ? "accent" : "neutral-100"],
}
})
</script> </script>
{#if $canSign} {#if $canSign}
@ -110,7 +82,7 @@
plugins={[Interaction, DayGrid]} plugins={[Interaction, DayGrid]}
options={{ options={{
view: "dayGridMonth", view: "dayGridMonth",
events: calendarEvents, events: $calendarEvents,
dateClick: onDateClick, dateClick: onDateClick,
eventClick: onEventClick, eventClick: onEventClick,
eventContent: getEventContent, eventContent: getEventContent,

View File

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import NoteContentKind1 from "src/app/shared/NoteContentKind1.svelte" import NoteContentKind1 from "src/app/shared/NoteContentKind1.svelte"
import {groups} from "src/engine" import {deriveGroupMeta} from "src/engine"
export let address export let address
export let truncate = true export let truncate = true
const group = groups.key(address) const meta = deriveGroupMeta(address)
</script> </script>
<NoteContentKind1 <NoteContentKind1
note={{content: $group?.meta?.about || ""}} note={{content: $meta?.about || ""}}
minLength={100} minLength={100}
maxLength={140} maxLength={140}
showEntire={!truncate} /> showEntire={!truncate} />

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {feedFromFilter} from "@welshman/feeds" import {EVENT_TIME} from "@welshman/util"
import Calendar from "src/app/shared/Calendar.svelte" import Calendar from "src/app/shared/Calendar.svelte"
export let address export let address
</script> </script>
<Calendar group={address} feed={feedFromFilter({kinds: [31923], "#a": [address]})} /> <Calendar group={address} filter={{kinds: [EVENT_TIME], "#a": [address]}} />

View File

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import ImageCircle from "src/partials/ImageCircle.svelte" import ImageCircle from "src/partials/ImageCircle.svelte"
import PlaceholderCircle from "src/app/shared/PlaceholderCircle.svelte" import PlaceholderCircle from "src/app/shared/PlaceholderCircle.svelte"
import {groups} from "src/engine" import {deriveGroupMeta} from "src/engine"
export let address export let address
const group = groups.key(address) const meta = deriveGroupMeta(address)
</script> </script>
{#if $group?.meta?.picture} {#if $meta?.image}
<ImageCircle src={$group.meta.picture} class={$$props.class} /> <ImageCircle src={$meta.image} class={$$props.class} />
{:else} {:else}
<PlaceholderCircle pubkey={address} class={$$props.class} /> <PlaceholderCircle pubkey={address} class={$$props.class} />
{/if} {/if}

View File

@ -1,24 +1,7 @@
<script context="module" lang="ts">
export type Values = {
id?: string
type: string
feeds: string[][]
relays: string[]
members?: string[]
list_publicly: boolean
meta: {
name: string
about: string
picture: string
banner: string
}
}
</script>
<script lang="ts"> <script lang="ts">
import {join, uniqBy} from "ramda" import {join, uniqBy} from "ramda"
import {ucFirst} from "hurdak" import {ucFirst} from "hurdak"
import {Address} from "@welshman/util" import {Address, GROUP, COMMUNITY} from "@welshman/util"
import {toSpliced} from "src/util/misc" import {toSpliced} from "src/util/misc"
import {fly} from "src/util/transition" import {fly} from "src/util/transition"
import {formCtrl} from "src/partials/utils" import {formCtrl} from "src/partials/utils"
@ -35,11 +18,12 @@
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import PersonSelect from "src/app/shared/PersonSelect.svelte" import PersonSelect from "src/app/shared/PersonSelect.svelte"
import type {GroupMeta} from "src/domain"
import {normalizeRelayUrl} from "src/domain" import {normalizeRelayUrl} from "src/domain"
import {env, hints, relaySearch, feedSearch} from "src/engine" import {env, hints, relaySearch, feedSearch} from "src/engine"
export let onSubmit export let onSubmit
export let values: Values export let values: GroupMeta & {members: string[]}
export let mode = "create" export let mode = "create"
export let showMembers = false export let showMembers = false
export let buttonText = "Save" export let buttonText = "Save"
@ -83,7 +67,7 @@
<div class="mb-4 flex flex-col items-center justify-center"> <div class="mb-4 flex flex-col items-center justify-center">
<Heading>{ucFirst(mode)} Group</Heading> <Heading>{ucFirst(mode)} Group</Heading>
<p> <p>
{#if values.type === "open"} {#if values.kind === COMMUNITY}
An open forum where anyone can participate. An open forum where anyone can participate.
{:else} {:else}
A private place where members can talk. A private place where members can talk.
@ -92,30 +76,30 @@
</div> </div>
<div class="flex w-full flex-col gap-8"> <div class="flex w-full flex-col gap-8">
<Field label="Name"> <Field label="Name">
<Input bind:value={values.meta.name}> <Input bind:value={values.name}>
<i slot="before" class="fa fa-clipboard" /> <i slot="before" class="fa fa-clipboard" />
</Input> </Input>
<div slot="info">The name of the group</div> <div slot="info">The name of the group</div>
</Field> </Field>
<Field label="Picture"> <Field label="Picture">
<ImageInput <ImageInput
bind:value={values.meta.picture} bind:value={values.image}
icon="image-portrait" icon="image-portrait"
maxWidth={480} maxWidth={480}
maxHeight={480} /> maxHeight={480} />
<div slot="info">A picture for the group</div> <div slot="info">A picture for the group</div>
</Field> </Field>
<Field label="Banner"> <Field label="Banner">
<ImageInput bind:value={values.meta.banner} icon="image" maxWidth={4000} maxHeight={4000} /> <ImageInput bind:value={values.banner} icon="image" maxWidth={4000} maxHeight={4000} />
<div slot="info">A banner image for the group</div> <div slot="info">A banner image for the group</div>
</Field> </Field>
<Field label="About"> <Field label="About">
<Textarea bind:value={values.meta.about} /> <Textarea bind:value={values.about} />
<div slot="info">The group's decription</div> <div slot="info">The group's decription</div>
</Field> </Field>
{#if values.type === "closed"} {#if values.kind === GROUP}
<FieldInline label="List Publicly"> <FieldInline label="List Publicly">
<Toggle bind:value={values.list_publicly} /> <Toggle bind:value={values.listing_is_public} />
<div slot="info"> <div slot="info">
If enabled, this will generate a public listing for the group. The member list and group If enabled, this will generate a public listing for the group. The member list and group
messages will not be published. messages will not be published.

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {deriveGroup, displayGroup, loadGroups} from "src/engine" import {displayGroupMeta} from "src/domain"
import {deriveGroupMeta, loadGroups} from "src/engine"
export let address export let address
const group = deriveGroup(address) const meta = deriveGroupMeta(address)
const path = router.at("groups").of(address).at("notes").toString() const path = router.at("groups").of(address).at("notes").toString()
loadGroups([address]) loadGroups([address])
@ -13,6 +14,6 @@
<span class={$$props.class}> <span class={$$props.class}>
<Anchor modal underline href={path}> <Anchor modal underline href={path}>
{displayGroup($group)} {displayGroupMeta($meta)}
</Anchor> </Anchor>
</span> </span>

View File

@ -1,19 +1,27 @@
<script> <script>
import {ellipsize} from "hurdak" import {ellipsize} from "hurdak"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {remove} from "@welshman/lib" import {remove, intersection} from "@welshman/lib"
import {isGroupAddress, isCommunityAddress} from "@welshman/util"
import Chip from "src/partials/Chip.svelte" import Chip from "src/partials/Chip.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import GroupCircle from "src/app/shared/GroupCircle.svelte" import GroupCircle from "src/app/shared/GroupCircle.svelte"
import PersonCircles from "src/app/shared/PersonCircles.svelte" import PersonCircles from "src/app/shared/PersonCircles.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {displayGroup, deriveGroup, userFollowsByCommunity, pubkey} from "src/engine" import {displayGroupMeta} from "src/domain"
import {deriveGroupMeta, userFollows, communityListsByAddress, pubkey} from "src/engine"
export let address export let address
export let modal = false export let modal = false
const group = deriveGroup(address) const meta = deriveGroupMeta(address)
const members = derived(userFollowsByCommunity, $m => remove($pubkey, $m.get(address) || [])) const members = derived(communityListsByAddress, $m => {
const allMembers = $m.get(address)?.map(l => l.event.pubkey) || []
const otherMembers = remove($pubkey, allMembers)
const followMembers = intersection(otherMembers, Array.from($userFollows))
return followMembers
})
const enter = () => { const enter = () => {
const route = router.at("groups").of(address).at("notes") const route = router.at("groups").of(address).at("notes")
@ -31,20 +39,20 @@
<div class="flex min-w-0 flex-grow flex-col justify-start gap-1"> <div class="flex min-w-0 flex-grow flex-col justify-start gap-1">
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<h2 class="text-xl font-bold"> <h2 class="text-xl font-bold">
{displayGroup($group)} {displayGroupMeta($meta)}
</h2> </h2>
<slot name="actions"> <slot name="actions">
{#if address.startsWith("34550:")} {#if isCommunityAddress(address)}
<Chip class="text-sm text-neutral-200"><i class="fa fa-unlock" /> Open</Chip> <Chip class="text-sm text-neutral-200"><i class="fa fa-unlock" /> Open</Chip>
{/if} {/if}
{#if address.startsWith("35834:")} {#if isGroupAddress(address)}
<Chip class="text-sm text-neutral-200"><i class="fa fa-lock" /> Closed</Chip> <Chip class="text-sm text-neutral-200"><i class="fa fa-lock" /> Closed</Chip>
{/if} {/if}
</slot> </slot>
</div> </div>
{#if $group.meta?.about} {#if $meta?.about}
<p class="text-start text-neutral-100"> <p class="text-start text-neutral-100">
{ellipsize($group.meta.about, 300)} {ellipsize($meta.about, 300)}
</p> </p>
{/if} {/if}
{#if $members.length > 0} {#if $members.length > 0}

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {isGroupAddress} from "@welshman/util"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import PersonSummary from "src/app/shared/PersonSummary.svelte" import PersonSummary from "src/app/shared/PersonSummary.svelte"
@ -24,7 +25,7 @@
<Card interactive on:click={() => openPerson(pubkey)}> <Card interactive on:click={() => openPerson(pubkey)}>
<PersonSummary inert {pubkey}> <PersonSummary inert {pubkey}>
<div slot="actions" on:click|stopPropagation> <div slot="actions" on:click|stopPropagation>
{#if $adminKey && pubkey !== $session.pubkey} {#if $adminKey && pubkey !== $session.pubkey && isGroupAddress(address)}
<Anchor on:click={remove} button accent>Remove</Anchor> <Anchor on:click={remove} button accent>Remove</Anchor>
{/if} {/if}
</div> </div>

View File

@ -1,15 +1,26 @@
<script lang="ts"> <script lang="ts">
import {COMMUNITIES} from "@welshman/util"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import GroupMember from "src/app/shared/GroupMember.svelte" import GroupMember from "src/app/shared/GroupMember.svelte"
import {deriveGroup} from 'src/engine' import {load, hints, deriveGroup, communityListsByAddress} from "src/engine"
export let address export let address
const group = deriveGroup(address) const group = deriveGroup(address)
const filters = [{kinds: [COMMUNITIES], "#a": [address]}]
$: members =
$group.members || $communityListsByAddress.get(address)?.map(l => l.event.pubkey) || []
load({
filters,
skipCache: true,
relays: hints.merge([hints.WithinContext(address), hints.User()]).getUrls(),
})
</script> </script>
<FlexColumn> <FlexColumn>
{#each $group.members || [] as pubkey (pubkey)} {#each members as pubkey (pubkey)}
<GroupMember {address} {pubkey} /> <GroupMember {address} {pubkey} />
{:else} {:else}
<p class="text-center py-12">No members found.</p> <p class="text-center py-12">No members found.</p>

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import {groups, displayGroup} from "src/engine" import {displayGroupMeta} from "src/domain"
import {deriveGroupMeta} from "src/engine"
export let address export let address
const group = groups.key(address) const meta = deriveGroupMeta(address)
</script> </script>
<span class={$$props.class}>{displayGroup($group)}</span> <span class={$$props.class}>{displayGroupMeta($meta)}</span>

View File

@ -9,12 +9,11 @@
import Feed from "src/app/shared/Feed.svelte" import Feed from "src/app/shared/Feed.svelte"
import NoteCreateInline from "src/app/shared/NoteCreateInline.svelte" import NoteCreateInline from "src/app/shared/NoteCreateInline.svelte"
import {makeFeed, readFeed} from "src/domain" import {makeFeed, readFeed} from "src/domain"
import {hints, repository, canSign, deriveGroup, load} from "src/engine" import {hints, repository, canSign, deriveGroupMeta, load} from "src/engine"
export let address export let address
const group = deriveGroup(address) const meta = deriveGroupMeta(address)
const mainFeed = feedFromFilter({kinds: remove(30402, noteKinds), "#a": [address]}) const mainFeed = feedFromFilter({kinds: remove(30402, noteKinds), "#a": [address]})
const setActiveTab = tab => { const setActiveTab = tab => {
@ -27,7 +26,7 @@
let feeds = [{name: "feed", feed: makeFeed({definition: mainFeed})}] let feeds = [{name: "feed", feed: makeFeed({definition: mainFeed})}]
let feed = makeFeed({definition: mainFeed}) let feed = makeFeed({definition: mainFeed})
for (const feed of $group.feeds || []) { for (const feed of $meta?.feeds || []) {
const [address, relay = "", name = ""] = feed.slice(1) const [address, relay = "", name = ""] = feed.slice(1)
if (!Address.isAddress(address)) { if (!Address.isAddress(address)) {

View File

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import {isCommunityAddress} from "@welshman/util"
import Chip from "src/partials/Chip.svelte" import Chip from "src/partials/Chip.svelte"
import GroupCircle from "src/app/shared/GroupCircle.svelte" import GroupCircle from "src/app/shared/GroupCircle.svelte"
import GroupAbout from "src/app/shared/GroupAbout.svelte" import GroupAbout from "src/app/shared/GroupAbout.svelte"
import GroupName from "src/app/shared/GroupName.svelte" import GroupName from "src/app/shared/GroupName.svelte"
import {groups} from "src/engine" import {deriveGroupMeta} from "src/engine"
export let address export let address
export let hideAbout = false export let hideAbout = false
const group = groups.key(address) const meta = deriveGroupMeta(address)
</script> </script>
<div class="flex gap-4 text-neutral-100"> <div class="flex gap-4 text-neutral-100">
@ -18,7 +19,7 @@
<div class="flex items-center"> <div class="flex items-center">
<GroupName class="text-2xl" {address} /> <GroupName class="text-2xl" {address} />
<Chip class="scale-75 border-neutral-200 text-neutral-200"> <Chip class="scale-75 border-neutral-200 text-neutral-200">
{#if address.startsWith("34550:")} {#if isCommunityAddress(address)}
<i class="fa fa-unlock" /> <i class="fa fa-unlock" />
Open Open
{:else} {:else}
@ -29,7 +30,7 @@
</div> </div>
<slot name="actions" class="hidden xs:block" /> <slot name="actions" class="hidden xs:block" />
</div> </div>
{#if !hideAbout && $group?.meta?.about} {#if !hideAbout && $meta?.about}
<GroupAbout {address} /> <GroupAbout {address} />
{/if} {/if}
</div> </div>

View File

@ -97,7 +97,7 @@
<form on:submit|preventDefault={() => onSubmit()}> <form on:submit|preventDefault={() => onSubmit()}>
<AltColor background class="z-feature flex gap-4 overflow-hidden rounded p-3 text-neutral-100"> <AltColor background class="z-feature flex gap-4 overflow-hidden rounded p-3 text-neutral-100">
<PersonCircle class="h-10 w-10" pubkey={$pubkey} /> <PersonCircle class="h-10 w-10" pubkey={$pubkey} />
<div class="w-full"> <div class="w-full min-w-0">
<Compose placeholder="What's up?" bind:this={compose} {onSubmit} style="min-height: 3em;" /> <Compose placeholder="What's up?" bind:this={compose} {onSubmit} style="min-height: 3em;" />
<div class="p-2"> <div class="p-2">
<NoteImages bind:this={images} bind:compose includeInContent /> <NoteImages bind:this={images} bind:compose includeInContent />

View File

@ -1,21 +1,15 @@
<script lang="ts"> <script lang="ts">
import { import {identity} from "ramda"
Scope,
feedFromFilter,
makeIntersectionFeed,
makeKindFeed,
makeScopeFeed,
} from "@welshman/feeds"
import Calendar from "src/app/shared/Calendar.svelte" import Calendar from "src/app/shared/Calendar.svelte"
import {env, loadGroupMessages} from "src/engine" import {env, loadGroupMessages, pubkey, userFollows} from "src/engine"
const feed = $env.FORCE_GROUP const filter = $env.FORCE_GROUP
? feedFromFilter({kinds: [31923], "#a": [$env.FORCE_GROUP]}) ? {kinds: [31923], "#a": [$env.FORCE_GROUP]}
: makeIntersectionFeed(makeKindFeed(31923), makeScopeFeed(Scope.Self, Scope.Follows)) : {kinds: [31923], authors: [$pubkey, ...$userFollows].filter(identity)}
if ($env.FORCE_GROUP) { if ($env.FORCE_GROUP) {
loadGroupMessages([$env.FORCE_GROUP]) loadGroupMessages([$env.FORCE_GROUP])
} }
</script> </script>
<Calendar {feed} /> <Calendar {filter} />

View File

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import {nth} from "@welshman/lib"
import {COMMUNITY, GROUP} from "@welshman/util"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import type {Values} from "src/app/shared/GroupDetailsForm.svelte"
import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte" import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte"
import type {GroupMeta} from "src/domain"
import { import {
env, env,
pubkey, pubkey,
@ -19,34 +21,38 @@
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
const initialValues = { const initialValues = {
type: null, kind: null,
name: "",
about: "",
image: "",
banner: "",
feeds: [], feeds: [],
relays: $env.PLATFORM_RELAYS, relays: $env.PLATFORM_RELAYS.map(url => ["relay", url]),
listing_is_public: false,
members: [$pubkey], members: [$pubkey],
list_publicly: false, moderators: [],
meta: { identifier: "",
name: "",
about: "",
picture: "",
banner: "",
},
} }
const setType = type => { const setKind = kind => {
initialValues.type = type initialValues.kind = kind
} }
const onSubmit = async ({type, feeds, relays, members, list_publicly, meta}: Values) => { const onSubmit = async ({
const kind = type === "open" ? 34550 : 35834 kind,
const {id, address} = initGroup(kind, relays) members,
listing_is_public,
...meta
}: GroupMeta & {members: string[]}) => {
const {identifier, address} = initGroup(kind, meta.relays.map(nth(1)))
await publishAdminKeyShares(address, [$pubkey]) await publishAdminKeyShares(address, [$pubkey])
if (type === "open") { if (kind === COMMUNITY) {
await publishCommunityMeta(address, id, feeds, relays, meta) await publishCommunityMeta(address, identifier, meta)
await publishCommunitiesList(deriveUserCommunities().get().concat(address)) await publishCommunitiesList(deriveUserCommunities().get().concat(address))
} else { } else {
await publishGroupMeta(address, id, feeds, relays, meta, list_publicly) await publishGroupMeta(address, identifier, meta, listing_is_public)
await publishGroupMembers(address, "set", members) await publishGroupMembers(address, "set", members)
await publishGroupInvites(address, members) await publishGroupInvites(address, members)
} }
@ -55,12 +61,12 @@
} }
</script> </script>
{#if !initialValues.type} {#if !initialValues.kind}
<div class="mb-4 flex flex-col items-center justify-center"> <div class="mb-4 flex flex-col items-center justify-center">
<Heading>Create Group</Heading> <Heading>Create Group</Heading>
<p>What type of group would you like to create?</p> <p>What type of group would you like to create?</p>
</div> </div>
<Card interactive on:click={() => setType("open")}> <Card interactive on:click={() => setKind(COMMUNITY)}>
<FlexColumn> <FlexColumn>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="flex items-center gap-4 text-xl"> <p class="flex items-center gap-4 text-xl">
@ -75,7 +81,7 @@
</p> </p>
</FlexColumn> </FlexColumn>
</Card> </Card>
<Card interactive on:click={() => setType("closed")}> <Card interactive on:click={() => setKind(GROUP)}>
<FlexColumn> <FlexColumn>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="flex items-center gap-4 text-xl"> <p class="flex items-center gap-4 text-xl">
@ -94,11 +100,11 @@
<div class="relative"> <div class="relative">
<i <i
class="fa fa-2x fa-arrow-left absolute top-12 cursor-pointer" class="fa fa-2x fa-arrow-left absolute top-12 cursor-pointer"
on:click={() => setType(null)} /> on:click={() => setKind(null)} />
<GroupDetailsForm <GroupDetailsForm
{onSubmit} {onSubmit}
values={initialValues} values={initialValues}
showMembers={initialValues.type === "closed"} showMembers={initialValues.kind === GROUP}
buttonText={`Create ${initialValues.type} group`} /> buttonText={initialValues.kind === GROUP ? "Create closed group" : "Create open group"} />
</div> </div>
{/if} {/if}

View File

@ -2,12 +2,13 @@
import {showInfo} from "src/partials/Toast.svelte" import {showInfo} from "src/partials/Toast.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Subheading from "src/partials/Subheading.svelte" import Subheading from "src/partials/Subheading.svelte"
import {groups, createAndPublish, hints, deriveAdminKeyForGroup, displayGroup} from "src/engine" import {displayGroupMeta} from "src/domain"
import {deriveGroupMeta, createAndPublish, hints, deriveAdminKeyForGroup} from "src/engine"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
export let address export let address
const group = groups.key(address) const meta = deriveGroupMeta(address)
const adminKey = deriveAdminKeyForGroup(address) const adminKey = deriveAdminKeyForGroup(address)
const abort = () => router.pop() const abort = () => router.pop()
@ -31,7 +32,7 @@
</script> </script>
<Subheading>Delete Group</Subheading> <Subheading>Delete Group</Subheading>
<p>Are you sure you want to delete {displayGroup($group)}?</p> <p>Are you sure you want to delete {displayGroupMeta($meta)}?</p>
<p> <p>
This will only hide this group from supporting clients. Messages sent to the group may not be This will only hide this group from supporting clients. Messages sent to the group may not be
deleted from relays. deleted from relays.

View File

@ -13,13 +13,14 @@
import GroupMembers from "src/app/shared/GroupMembers.svelte" import GroupMembers from "src/app/shared/GroupMembers.svelte"
import GroupAdmin from "src/app/shared/GroupAdmin.svelte" import GroupAdmin from "src/app/shared/GroupAdmin.svelte"
import GroupRestrictAccess from "src/app/shared/GroupRestrictAccess.svelte" import GroupRestrictAccess from "src/app/shared/GroupRestrictAccess.svelte"
import {displayGroupMeta} from "src/domain"
import { import {
env, env,
GroupAccess, GroupAccess,
displayGroup,
loadPubkeys, loadPubkeys,
groupRequests, groupRequests,
deriveGroup, deriveGroup,
deriveGroupMeta,
deriveAdminKeyForGroup, deriveAdminKeyForGroup,
deriveSharedKeyForGroup, deriveSharedKeyForGroup,
deriveIsGroupMember, deriveIsGroupMember,
@ -35,6 +36,7 @@
export let claim = "" export let claim = ""
const group = deriveGroup(address) const group = deriveGroup(address)
const meta = deriveGroupMeta(address)
const status = deriveGroupStatus(address) const status = deriveGroupStatus(address)
const isGroupMember = deriveIsGroupMember(address) const isGroupMember = deriveIsGroupMember(address)
const sharedKey = deriveSharedKeyForGroup(address) const sharedKey = deriveSharedKeyForGroup(address)
@ -71,7 +73,7 @@
tabs.push("market") tabs.push("market")
} }
if ($sharedKey) { if ($sharedKey || address.startsWith("34550")) {
tabs.push("members") tabs.push("members")
} else if (activeTab === "members") { } else if (activeTab === "members") {
activeTab = "notes" activeTab = "notes"
@ -86,21 +88,21 @@
$: ({rgb, rgba} = $themeBackgroundGradient) $: ({rgb, rgba} = $themeBackgroundGradient)
document.title = $group?.meta?.name || "Group Detail" document.title = $meta?.name || "Group Detail"
</script> </script>
<div <div
class="absolute left-0 top-0 h-64 w-full" class="absolute left-0 top-0 h-64 w-full"
style={`z-index: -1; style={`z-index: -1;
background-size: cover; background-size: cover;
background-image: linear-gradient(to bottom, ${rgba}, ${rgb}), url('${$group?.meta?.banner}')`} /> background-image: linear-gradient(to bottom, ${rgba}, ${rgb}), url('${$meta?.banner}')`} />
<div class="flex gap-4 text-neutral-100"> <div class="flex gap-4 text-neutral-100">
<GroupCircle {address} class="mt-1 h-10 w-10 sm:h-32 sm:w-32" /> <GroupCircle {address} class="mt-1 h-10 w-10 sm:h-32 sm:w-32" />
<div class="flex min-w-0 flex-grow flex-col gap-4"> <div class="flex min-w-0 flex-grow flex-col gap-4">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<Anchor on:click={() => setActiveTab("notes")} class="text-2xl" <Anchor on:click={() => setActiveTab("notes")} class="text-2xl"
>{displayGroup($group)}</Anchor> >{displayGroupMeta($meta)}</Anchor>
<GroupActions {address} {claim} /> <GroupActions {address} {claim} />
</div> </div>
<GroupAbout {address} /> <GroupAbout {address} />

View File

@ -1,46 +1,39 @@
<script lang="ts"> <script lang="ts">
import {prop} from "ramda" import {prop} from "ramda"
import {COMMUNITY} from "@welshman/util"
import {showInfo} from "src/partials/Toast.svelte" import {showInfo} from "src/partials/Toast.svelte"
import type {Values} from "src/app/shared/GroupDetailsForm.svelte"
import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte" import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte"
import type {GroupMeta} from "src/domain"
import { import {
deriveGroup,
deleteGroupMeta, deleteGroupMeta,
publishGroupMeta, publishGroupMeta,
publishCommunityMeta, publishCommunityMeta,
getGroupId, deriveGroupMeta,
getGroupName,
deriveGroup,
} from "src/engine" } from "src/engine"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
export let address export let address
const group = deriveGroup(address) const group = deriveGroup(address)
const meta = deriveGroupMeta(address)
const initialValues = {...$meta, members: $group?.members || []}
const initialValues = { const onSubmit = async ({
id: getGroupId($group), kind,
type: address.startsWith("34550:") ? "open" : "closed", identifier,
feeds: $group.feeds || [], listing_is_public,
relays: $group.relays || [], ...meta
list_publicly: $group.listing_is_public, }: GroupMeta & {members: string[]}) => {
meta: {
name: getGroupName($group),
about: $group.meta?.about || "",
picture: $group.meta?.picture || "",
banner: $group.meta?.banner || "",
},
}
const onSubmit = async ({id, type, list_publicly, feeds, relays, meta}: Values) => {
// If we're switching group listing visibility, delete the old listing // If we're switching group listing visibility, delete the old listing
if ($group.listing_is_public && !list_publicly) { if (listing_is_public && !initialValues.listing_is_public) {
await prop("result", await deleteGroupMeta($group.address)) await prop("result", await deleteGroupMeta(address))
} }
if (type === "open") { if (kind === COMMUNITY) {
await publishCommunityMeta(address, id, feeds, relays, meta) await publishCommunityMeta(address, identifier, meta)
} else { } else {
await publishGroupMeta(address, id, feeds, relays, meta, list_publicly) await publishGroupMeta(address, identifier, meta, listing_is_public)
} }
showInfo("Your group has been updated!") showInfo("Your group has been updated!")

View File

@ -1,18 +1,20 @@
<script lang="ts"> <script lang="ts">
import {toNostrURI} from "@welshman/util" import {nth} from "@welshman/lib"
import {toNostrURI, Address} from "@welshman/util"
import {nsecEncode} from "src/util/nostr" import {nsecEncode} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Popover from "src/partials/Popover.svelte" import Popover from "src/partials/Popover.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import CopyValue from "src/partials/CopyValue.svelte" import CopyValue from "src/partials/CopyValue.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte" import RelayCard from "src/app/shared/RelayCard.svelte"
import {groups, deriveAdminKeyForGroup, getGroupNaddr} from "src/engine" import {deriveGroupMeta, deriveAdminKeyForGroup} from "src/engine"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
export let address export let address
const group = groups.key(address) const meta = deriveGroupMeta(address)
const adminKey = deriveAdminKeyForGroup(address) const adminKey = deriveAdminKeyForGroup(address)
const naddr = Address.from(address, $meta?.relays.map(nth(1)) || []).toNaddr()
const shareAdminKey = () => { const shareAdminKey = () => {
popover?.hide() popover?.hide()
@ -24,7 +26,7 @@
<h1 class="staatliches text-2xl">Details</h1> <h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Group ID" value={address} /> <CopyValue label="Group ID" value={address} />
<CopyValue label="Link" value={toNostrURI(getGroupNaddr($group))} /> <CopyValue label="Link" value={toNostrURI(naddr)} />
{#if $adminKey} {#if $adminKey}
<CopyValue isPassword label="Admin key" value={$adminKey.privkey} encode={nsecEncode}> <CopyValue isPassword label="Admin key" value={$adminKey.privkey} encode={nsecEncode}>
<div slot="label" class="flex gap-2"> <div slot="label" class="flex gap-2">
@ -46,12 +48,12 @@
</div> </div>
</CopyValue> </CopyValue>
{/if} {/if}
{#if $group.relays?.length > 0} {#if $meta?.relays.length > 0}
<h1 class="staatliches text-2xl">Relays</h1> <h1 class="staatliches text-2xl">Relays</h1>
<p>This group uses the following relays:</p> <p>This group uses the following relays:</p>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $group.relays as url} {#each $meta.relays as tag}
<RelayCard {url} /> <RelayCard url={tag[1]} />
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -1,7 +1,9 @@
<script> <script>
import {onMount} from "svelte" import {onMount} from "svelte"
import {filter, assoc} from "ramda" import {filter, reject, assoc} from "ramda"
import {now} from "@welshman/lib" import {derived} from "svelte/store"
import {now, shuffle} from "@welshman/lib"
import {GROUP, COMMUNITY, getAddress, getIdFilters} from "@welshman/util"
import {createScroller} from "src/util/misc" import {createScroller} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
@ -11,39 +13,37 @@
load, load,
hints, hints,
groups, groups,
repository,
getGroupReqInfo,
loadGiftWraps, loadGiftWraps,
loadGroupMessages, loadGroupMessages,
deriveIsGroupMember, deriveIsGroupMember,
updateCurrentSession, updateCurrentSession,
searchGroups, communityListsByAddress,
searchGroupMeta,
groupMeta,
} from "src/engine" } from "src/engine"
const loadMore = async () => { const loadMore = async () => {
limit += 20 limit += 20
} }
const userIsMember = g => deriveIsGroupMember(g.address, true).get() const userIsMember = meta => deriveIsGroupMember(getAddress(meta.event), true).get()
const userGroups = groups.derived( const userGroupMeta = derived(groupMeta, filter(userIsMember))
filter(g => !repository.deletes.has(g.address) && userIsMember(g)),
)
let q = "" let q = ""
let limit = 20 let limit = 20
let element = null let element = null
$: otherGroups = $searchGroups(q) $: otherGroupMeta = reject(userIsMember, $searchGroupMeta(q)).slice(0, limit)
.filter(g => !userIsMember(g))
.slice(0, limit)
document.title = "Groups" document.title = "Groups"
onMount(() => { onMount(() => {
const {admins} = getGroupReqInfo()
const scroller = createScroller(loadMore, {element})
const loader = loadGiftWraps() const loader = loadGiftWraps()
const scroller = createScroller(loadMore, {element})
const communityAddrs = Array.from($communityListsByAddress.keys()).filter(
a => !groups.key(a).get()?.meta,
)
updateCurrentSession(assoc("groups_last_synced", now())) updateCurrentSession(assoc("groups_last_synced", now()))
@ -51,15 +51,15 @@
load({ load({
skipCache: true, skipCache: true,
forcePlatform: false,
relays: hints.User().getUrls(), relays: hints.User().getUrls(),
filters: [{kinds: [35834, 34550], authors: admins}], filters: [{kinds: [GROUP, COMMUNITY], limit: 1000 - communityAddrs.length}],
}) })
load({ load({
skipCache: true, skipCache: true,
forcePlatform: false,
relays: hints.User().getUrls(), relays: hints.User().getUrls(),
filters: [{kinds: [35834, 34550], limit: 500}], filters: getIdFilters(shuffle(communityAddrs).slice(0, 1000)),
}) })
return () => { return () => {
@ -79,8 +79,8 @@
<i class="fa-solid fa-plus" /> Create <i class="fa-solid fa-plus" /> Create
</Anchor> </Anchor>
</div> </div>
{#each $userGroups as group (group.address)} {#each $userGroupMeta as meta (meta.event.id)}
<GroupListItem address={group.address} /> <GroupListItem address={getAddress(meta.event)} />
{:else} {:else}
<p class="text-center py-8">You haven't yet joined any groups.</p> <p class="text-center py-8">You haven't yet joined any groups.</p>
{/each} {/each}
@ -88,7 +88,7 @@
<Input bind:value={q} type="text" class="flex-grow" placeholder="Search groups"> <Input bind:value={q} type="text" class="flex-grow" placeholder="Search groups">
<i slot="before" class="fa-solid fa-search" /> <i slot="before" class="fa-solid fa-search" />
</Input> </Input>
{#each otherGroups as group (group.address)} {#each otherGroupMeta as meta (meta.event.id)}
<GroupListItem address={group.address} /> <GroupListItem address={getAddress(meta.event)} />
{/each} {/each}
</FlexColumn> </FlexColumn>

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import {without} from "ramda" import {without} from "@welshman/lib"
import {difference} from "hurdak" import {difference} from "hurdak"
import {showInfo} from "src/partials/Toast.svelte" import {showInfo} from "src/partials/Toast.svelte"
import Field from "src/partials/Field.svelte" import Field from "src/partials/Field.svelte"
@ -11,9 +11,11 @@
import PersonSelect from "src/app/shared/PersonSelect.svelte" import PersonSelect from "src/app/shared/PersonSelect.svelte"
import type {GroupRequest} from "src/engine" import type {GroupRequest} from "src/engine"
import { import {
hints,
groups, groups,
groupRequests, groupRequests,
initSharedKey, initSharedKey,
deriveGroupMeta,
deriveSharedKeyForGroup, deriveSharedKeyForGroup,
publishGroupInvites, publishGroupInvites,
publishGroupEvictions, publishGroupEvictions,
@ -27,6 +29,7 @@
export let removeMembers = [] export let removeMembers = []
const group = groups.key(address) const group = groups.key(address)
const meta = deriveGroupMeta(address)
const sharedKey = deriveSharedKeyForGroup(address) const sharedKey = deriveSharedKeyForGroup(address)
const initialMembers = new Set( const initialMembers = new Set(
without(removeMembers, [...($group?.members || []), ...addMembers]), without(removeMembers, [...($group?.members || []), ...addMembers]),
@ -34,7 +37,7 @@
const onSubmit = () => { const onSubmit = () => {
if (!soft || !$sharedKey) { if (!soft || !$sharedKey) {
initSharedKey(address) initSharedKey(address, hints.WithinContext(address).getUrls())
} }
const allMembers = new Set(members) const allMembers = new Set(members)
@ -81,8 +84,8 @@
} }
// Re-publish group info // Re-publish group info
if (!soft && !$group.listing_is_public) { if (!soft && !$meta.listing_is_public) {
publishGroupMeta(address, $group.id, $group.feeds, $group.relays, $group.meta, false) publishGroupMeta(address, $meta.identifier, $meta, false)
} }
// Re-send invites. This could be optimized further, but it's useful to re-send to different relays. // Re-send invites. This could be optimized further, but it's useful to re-send to different relays.

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {zipObj, uniq, pluck} from "ramda" import {zipObj, pluck} from "ramda"
import {normalizeRelayUrl, Address} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import {updateIn} from "src/util/misc" import {updateIn} from "src/util/misc"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
@ -12,7 +12,7 @@
import GroupActions from "src/app/shared/GroupActions.svelte" import GroupActions from "src/app/shared/GroupActions.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte" import RelayCard from "src/app/shared/RelayCard.svelte"
import Onboarding from "src/app/views/Onboarding.svelte" import Onboarding from "src/app/views/Onboarding.svelte"
import {session, loadGroups, groups as allGroups} from "src/engine" import {session, loadGroups, groupHints} from "src/engine"
export let people = [] export let people = []
export let relays = [] export let relays = []
@ -30,24 +30,9 @@
loadGroups(pluck("address", parsedGroups) as string[], pluck("relay", parsedGroups) as string[]) loadGroups(pluck("address", parsedGroups) as string[], pluck("relay", parsedGroups) as string[])
// Add relay hints to groups so we can use them deep in the call stack
for (const {address, relay} of parsedGroups) { for (const {address, relay} of parsedGroups) {
const group = allGroups.key(address)
if (relay) { if (relay) {
const {identifier, pubkey} = Address.from(address) groupHints.update($gh => ({...$gh, [address]: [...$gh[address], relay]}))
group.update($g => {
const {relays = []} = $g
return {
...$g,
address,
id: identifier,
pubkey: pubkey,
relays: uniq([...relays, relay]),
}
})
} }
} }
</script> </script>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {prop} from "ramda"
import {without, identity} from "@welshman/lib" import {without, identity} from "@welshman/lib"
import {getAddress} from "@welshman/util"
import {onMount} from "svelte" import {onMount} from "svelte"
import {pickVals, toSpliced} from "src/util/misc" import {pickVals, toSpliced} from "src/util/misc"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
@ -15,8 +15,8 @@
import GroupCircle from "src/app/shared/GroupCircle.svelte" import GroupCircle from "src/app/shared/GroupCircle.svelte"
import PersonSelect from "src/app/shared/PersonSelect.svelte" import PersonSelect from "src/app/shared/PersonSelect.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {displayRelayUrl} from "src/domain" import {displayRelayUrl, displayGroupMeta} from "src/domain"
import {hints, relaySearch, searchGroups, displayGroup, deriveGroup} from "src/engine" import {hints, relaySearch, searchGroupMeta, groupMetaByAddress} from "src/engine"
export let initialPubkey = null export let initialPubkey = null
export let initialGroupAddress = null export let initialGroupAddress = null
@ -70,7 +70,7 @@
groups = toSpliced(groups, i, 1) groups = toSpliced(groups, i, 1)
} }
const displayGroupFromAddress = a => displayGroup(deriveGroup(a).get()) const displayGroupFromAddress = a => displayGroupMeta($groupMetaByAddress.get(a))
let relayInput, groupInput let relayInput, groupInput
let sections = [] let sections = []
@ -194,14 +194,14 @@
<SearchSelect <SearchSelect
value={null} value={null}
bind:this={groupInput} bind:this={groupInput}
search={$searchGroups} search={$searchGroupMeta}
getKey={prop("address")} displayItem={displayGroupMeta}
onChange={g => g && addGroup(g.address)} getKey={groupMeta => getAddress(groupMeta.event)}
displayItem={g => (g ? displayGroup(g) : "")}> onChange={groupMeta => groupMeta && addGroup(getAddress(groupMeta.event))}>
<i slot="before" class="fa fa-search" /> <i slot="before" class="fa fa-search" />
<div slot="item" let:item class="flex items-center gap-4 text-neutral-100"> <div slot="item" let:item class="flex items-center gap-4 text-neutral-100">
<GroupCircle address={item.address} class="h-5 w-5" /> <GroupCircle address={getAddress(item.event)} class="h-5 w-5" />
<GroupName address={item.address} /> <GroupName address={getAddress(item.event)} />
</div> </div>
</SearchSelect> </SearchSelect>
</FlexColumn> </FlexColumn>

41
src/domain/group.ts Normal file
View File

@ -0,0 +1,41 @@
import {fromPairs, nthEq} from '@welshman/lib'
import type {TrustedEvent} from "@welshman/util"
import {isSignedEvent} from "@welshman/util"
export type GroupMeta = {
kind: number
feeds: string[][]
relays: string[][]
moderators: string[][]
identifier: string
name: string
about: string
banner: string
image: string
listing_is_public: boolean
event?: TrustedEvent
}
export type PublishedGroupMeta = Omit<GroupMeta, "event"> & {
event: TrustedEvent
}
export const readGroupMeta = (event: TrustedEvent) => {
const meta = fromPairs(event.tags)
return {
event,
kind: event.kind,
feeds: event.tags.filter(nthEq(0, 'feed')),
relays: event.tags.filter(nthEq(0, 'relay')),
moderators: event.tags.filter(nthEq(0, 'p')),
identifier: meta.d,
name: meta.name,
about: meta.about,
banner: meta.banner,
image: meta.image || meta.picture,
listing_is_public: isSignedEvent(event),
} as PublishedGroupMeta
}
export const displayGroupMeta = (meta: GroupMeta) => meta?.name || meta?.identifier || "[no name]"

View File

@ -1,5 +1,6 @@
export * from "./collection" export * from "./collection"
export * from "./feed" export * from "./feed"
export * from "./group"
export * from "./handle" export * from "./handle"
export * from "./handler" export * from "./handler"
export * from "./kind" export * from "./kind"

View File

@ -51,7 +51,6 @@ import {
loadOne, loadOne,
createAndPublish, createAndPublish,
deriveAdminKeyForGroup, deriveAdminKeyForGroup,
deriveGroup,
deriveIsGroupMember, deriveIsGroupMember,
deriveSharedKeyForGroup, deriveSharedKeyForGroup,
displayProfileByPubkey, displayProfileByPubkey,
@ -250,7 +249,7 @@ export const updateZapper = async ({pubkey, created_at}, {lud16, lud06}) => {
// Key state management // Key state management
export const initSharedKey = address => { export const initSharedKey = (address: string, relays: string[]) => {
const privkey = generatePrivateKey() const privkey = generatePrivateKey()
const pubkey = getPublicKey(privkey) const pubkey = getPublicKey(privkey)
const key = { const key = {
@ -258,6 +257,7 @@ export const initSharedKey = address => {
pubkey: pubkey, pubkey: pubkey,
privkey: privkey, privkey: privkey,
created_at: now(), created_at: now(),
hints: relays,
} }
groupSharedKeys.key(pubkey).set(key) groupSharedKeys.key(pubkey).set(key)
@ -266,24 +266,24 @@ export const initSharedKey = address => {
} }
export const initGroup = (kind, relays) => { export const initGroup = (kind, relays) => {
const id = randomId() const identifier = randomId()
const privkey = generatePrivateKey() const privkey = generatePrivateKey()
const pubkey = getPublicKey(privkey) const pubkey = getPublicKey(privkey)
const address = `${kind}:${pubkey}:${id}` const address = `${kind}:${pubkey}:${identifier}`
const sharedKey = kind === 35834 ? initSharedKey(address) : null const sharedKey = kind === 35834 ? initSharedKey(address, relays) : null
const adminKey = { const adminKey = {
group: address, group: address,
pubkey: pubkey, pubkey: pubkey,
privkey: privkey, privkey: privkey,
created_at: now(), created_at: now(),
relays, hints: relays,
} }
groupAdminKeys.key(pubkey).set(adminKey) groupAdminKeys.key(pubkey).set(adminKey)
groups.key(address).set({id, pubkey, address, relays}) groups.key(address).set({id: identifier, pubkey, address})
return {id, address, adminKey, sharedKey} return {identifier, address, adminKey, sharedKey}
} }
// Most people don't have access to nip44 yet, send nip04-encrypted fallbacks for: // Most people don't have access to nip44 yet, send nip04-encrypted fallbacks for:
@ -339,16 +339,16 @@ export const publishToGroupAdmin = async (address, template) => {
} }
} }
export const publishAsGroupAdminPublicly = async (address, template) => { export const publishAsGroupAdminPublicly = async (address, template, relays = []) => {
const relays = hints.WithinContext(address).getUrls() const _relays = hints.merge([hints.fromRelays(relays), hints.WithinContext(address)]).getUrls()
const adminKey = deriveAdminKeyForGroup(address).get() const adminKey = deriveAdminKeyForGroup(address).get()
const event = await sign(template, {sk: adminKey.privkey}) const event = await sign(template, {sk: adminKey.privkey})
return publish({event, relays, forcePlatform: false}) return publish({event, relays: _relays, forcePlatform: false})
} }
export const publishAsGroupAdminPrivately = async (address, template) => { export const publishAsGroupAdminPrivately = async (address, template, relays = []) => {
const relays = hints.WithinContext(address).getUrls() const _relays = hints.merge([hints.fromRelays(relays), hints.WithinContext(address)]).getUrls()
const adminKey = deriveAdminKeyForGroup(address).get() const adminKey = deriveAdminKeyForGroup(address).get()
const sharedKey = deriveSharedKeyForGroup(address).get() const sharedKey = deriveSharedKeyForGroup(address).get()
@ -363,7 +363,7 @@ export const publishAsGroupAdminPrivately = async (address, template) => {
const pubs = [] const pubs = []
for (const rumor of rumors) { for (const rumor of rumors) {
pubs.push(publish({event: rumor.wrap, relays, forcePlatform: false})) pubs.push(publish({event: rumor.wrap, relays: _relays, forcePlatform: false}))
} }
return pubs return pubs
@ -485,7 +485,7 @@ export const publishKeyShares = async (address, pubkeys, template) => {
} }
export const publishAdminKeyShares = async (address, pubkeys) => { export const publishAdminKeyShares = async (address, pubkeys) => {
const {relays} = deriveGroup(address).get() const relays = hints.WithinContext(address).getUrls()
const {privkey} = deriveAdminKeyForGroup(address).get() const {privkey} = deriveAdminKeyForGroup(address).get()
const template = createEvent(24, { const template = createEvent(24, {
tags: [ tags: [
@ -501,7 +501,7 @@ export const publishAdminKeyShares = async (address, pubkeys) => {
} }
export const publishGroupInvites = async (address, pubkeys, gracePeriod = 0) => { export const publishGroupInvites = async (address, pubkeys, gracePeriod = 0) => {
const {relays} = deriveGroup(address).get() const relays = hints.WithinContext(address).getUrls()
const adminKey = deriveAdminKeyForGroup(address).get() const adminKey = deriveAdminKeyForGroup(address).get()
const {privkey} = deriveSharedKeyForGroup(address).get() const {privkey} = deriveSharedKeyForGroup(address).get()
const template = createEvent(24, { const template = createEvent(24, {
@ -535,40 +535,44 @@ export const publishGroupMembers = async (address, op, pubkeys) => {
return publishAsGroupAdminPrivately(address, template) return publishAsGroupAdminPrivately(address, template)
} }
export const publishCommunityMeta = (address, id, feeds, relays, meta) => { export const publishCommunityMeta = (address, identifier, meta) => {
const template = createEvent(34550, { const template = createEvent(34550, {
tags: [ tags: [
["d", id], ["d", identifier],
["name", meta.name], ["name", meta.name],
["description", meta.about], ["description", meta.about],
["banner", meta.banner], ["banner", meta.banner],
["image", meta.picture], ["picture", meta.image],
["image", meta.image],
...meta.feeds,
...meta.relays,
...meta.moderators,
...getClientTags(), ...getClientTags(),
...(feeds || []),
...(relays || []).map(url => ["relay", url]),
], ],
}) })
return publishAsGroupAdminPublicly(address, template) return publishAsGroupAdminPublicly(address, template, meta.relays)
} }
export const publishGroupMeta = (address, id, feeds, relays, meta, listPublicly) => { export const publishGroupMeta = (address, identifier, meta, listPublicly) => {
const template = createEvent(35834, { const template = createEvent(35834, {
tags: [ tags: [
["d", id], ["d", identifier],
["name", meta.name], ["name", meta.name],
["about", meta.about], ["about", meta.about],
["banner", meta.banner], ["banner", meta.banner],
["picture", meta.picture], ["picture", meta.image],
["image", meta.image],
...meta.feeds,
...meta.relays,
...meta.moderators,
...getClientTags(), ...getClientTags(),
...(feeds || []),
...(relays || []).map(url => ["relay", url]),
], ],
}) })
return listPublicly return listPublicly
? publishAsGroupAdminPublicly(address, template) ? publishAsGroupAdminPublicly(address, template, meta.relays)
: publishAsGroupAdminPrivately(address, template) : publishAsGroupAdminPrivately(address, template, meta.relays)
} }
export const deleteGroupMeta = address => export const deleteGroupMeta = address =>

View File

@ -22,27 +22,12 @@ export enum GroupAccess {
Revoked = "revoked", Revoked = "revoked",
} }
export type GroupMeta = {
name?: string
about?: string
banner?: string
picture?: string
}
export type Group = { export type Group = {
id: string id: string
pubkey: string pubkey: string
address: string address: string
meta?: GroupMeta
meta_updated_at?: number
feeds?: string[][]
feeds_updated_at?: number
relays?: string[]
relays_updated_at?: number
members?: string[] members?: string[]
recent_member_updates?: TrustedEvent[] recent_member_updates?: TrustedEvent[]
listing_is_public?: boolean
listing_is_public_updated?: boolean
} }
export type GroupKey = { export type GroupKey = {

View File

@ -5,8 +5,6 @@ import type {TrustedEvent} from "@welshman/util"
import { import {
Tags, Tags,
isShareableRelayUrl, isShareableRelayUrl,
Address,
getAddress,
getIdFilters, getIdFilters,
MUTES, MUTES,
FOLLOWS, FOLLOWS,
@ -43,7 +41,6 @@ import {
modifyGroupStatus, modifyGroupStatus,
setGroupStatus, setGroupStatus,
updateRecord, updateRecord,
updateStore,
updateSession, updateSession,
setSession, setSession,
updateZapper, updateZapper,
@ -109,63 +106,11 @@ projections.addHandler(24, (e: TrustedEvent) => {
groupAlerts.key(e.id).set({...e, group: address, type: "exit"}) groupAlerts.key(e.id).set({...e, group: address, type: "exit"})
} }
if (relays.length > 0) {
const {pubkey, identifier} = Address.from(address)
if (!groups.key(address).get()) {
groups.key(address).set({address, pubkey, id: identifier, relays})
}
}
setGroupStatus(recipient, address, e.created_at, { setGroupStatus(recipient, address, e.created_at, {
access: privkey ? GroupAccess.Granted : GroupAccess.Revoked, access: privkey ? GroupAccess.Granted : GroupAccess.Revoked,
}) })
}) })
// Group metadata
projections.addHandler(35834, (e: TrustedEvent) => {
const tags = Tags.fromEvent(e)
const meta = tags.asObject()
const address = getAddress(e)
const group = groups.key(address)
group.merge({address, id: meta.d, pubkey: e.pubkey})
updateStore(group, e.created_at, {
feeds: tags.whereKey("feed").unwrap(),
relays: tags.values("relay").valueOf(),
listing_is_public: !e.wrap,
meta: {
name: meta.name,
about: meta.about,
banner: meta.banner,
picture: meta.picture,
},
})
})
projections.addHandler(34550, (e: TrustedEvent) => {
const tags = Tags.fromEvent(e)
const meta = tags.asObject()
const address = getAddress(e)
const group = groups.key(address)
group.merge({address, id: meta.d, pubkey: e.pubkey})
updateStore(group, e.created_at, {
feeds: tags.whereKey("feed").unwrap(),
relays: tags.values("relay").valueOf(),
listing_is_public: true,
meta: {
name: meta.name,
about: meta.description,
banner: meta.image,
picture: meta.image,
},
})
})
projections.addHandler(27, (e: TrustedEvent) => { projections.addHandler(27, (e: TrustedEvent) => {
const address = Tags.fromEvent(e).groups().values().first() const address = Tags.fromEvent(e).groups().values().first()

View File

@ -1,5 +1,6 @@
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {batch, Fetch, noop, tryFunc, seconds, createMapOf, sleep, switcherFn} from "hurdak" import {batch, Fetch, noop, tryFunc, seconds, createMapOf, sleep, switcherFn} from "hurdak"
import {get} from "svelte/store"
import type {LoadOpts} from "@welshman/feeds" import type {LoadOpts} from "@welshman/feeds"
import { import {
FeedLoader, FeedLoader,
@ -38,7 +39,6 @@ import {
deriveUserCircles, deriveUserCircles,
getGroupReqInfo, getGroupReqInfo,
getCommunityReqInfo, getCommunityReqInfo,
groups,
dvmRequest, dvmRequest,
env, env,
getFollows, getFollows,
@ -60,6 +60,7 @@ import {
subscribe, subscribe,
subscribePersistent, subscribePersistent,
dufflepud, dufflepud,
deriveGroupMeta,
} from "src/engine/state" } from "src/engine/state"
import {updateCurrentSession, updateSession} from "src/engine/commands" import {updateCurrentSession, updateSession} from "src/engine/commands"
import {loadPubkeyRelays} from "src/engine/requests/pubkeys" import {loadPubkeyRelays} from "src/engine/requests/pubkeys"
@ -141,17 +142,17 @@ export const getStaleAddrs = (addrs: string[]) => {
for (const addr of addrs) { for (const addr of addrs) {
const attempts = attemptedAddrs.get(addr) | 0 const attempts = attemptedAddrs.get(addr) | 0
if (attempts > 1) { if (attempts > 0) {
continue continue
} }
attemptedAddrs.set(addr, attempts + 1) const meta = get(deriveGroupMeta(addr))
const group = groups.key(addr).get() if (!meta) {
if (!group?.meta) {
stale.add(addr) stale.add(addr)
} }
attemptedAddrs.set(addr, attempts + 1)
} }
return Array.from(stale) return Array.from(stale)

View File

@ -6,7 +6,9 @@ import {
PROFILE, PROFILE,
HANDLER_INFORMATION, HANDLER_INFORMATION,
NAMED_BOOKMARKS, NAMED_BOOKMARKS,
COMMUNITIES,
FEED, FEED,
MUTES,
FOLLOWS, FOLLOWS,
APP_DATA, APP_DATA,
} from "@welshman/util" } from "@welshman/util"
@ -50,10 +52,10 @@ const getFiltersForKey = (key: string, authors: string[]) => {
case "pubkey/relays": case "pubkey/relays":
return [{authors, kinds: [RELAYS]}] return [{authors, kinds: [RELAYS]}]
case "pubkey/profile": case "pubkey/profile":
return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION]}] return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION, COMMUNITIES]}]
case "pubkey/user": case "pubkey/user":
return [ return [
{authors, kinds: [PROFILE, RELAYS, FOLLOWS, APP_DATA]}, {authors, kinds: [PROFILE, RELAYS, MUTES, FOLLOWS, COMMUNITIES, APP_DATA]},
{authors, kinds: [APP_DATA], "#d": Object.values(appDataKeys)}, {authors, kinds: [APP_DATA], "#d": Object.values(appDataKeys)},
] ]
} }

View File

@ -2,7 +2,7 @@ import Fuse from "fuse.js"
import {openDB, deleteDB} from "idb" import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb" import type {IDBPDatabase} from "idb"
import {throttle} from "throttle-debounce" import {throttle} from "throttle-debounce"
import {derived, writable} from "svelte/store" import {get, derived, writable} from "svelte/store"
import {defer, doPipe, batch, randomInt, seconds, sleep, switcher} from "hurdak" import {defer, doPipe, batch, randomInt, seconds, sleep, switcher} from "hurdak"
import { import {
any, any,
@ -35,6 +35,7 @@ import {
uniq, uniq,
uniqBy, uniqBy,
now, now,
intersection,
sort, sort,
groupBy, groupBy,
indexBy, indexBy,
@ -42,6 +43,8 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import { import {
WRAP, WRAP,
COMMUNITY,
GROUP,
WRAP_NIP04, WRAP_NIP04,
COMMUNITIES, COMMUNITIES,
READ_RECEIPT, READ_RECEIPT,
@ -102,6 +105,7 @@ import type {
PublishedListFeed, PublishedListFeed,
PublishedSingleton, PublishedSingleton,
PublishedList, PublishedList,
PublishedGroupMeta,
RelayPolicy, RelayPolicy,
Handle, Handle,
} from "src/domain" } from "src/domain"
@ -129,6 +133,7 @@ import {
makeRelayPolicy, makeRelayPolicy,
filterRelaysByNip, filterRelaysByNip,
displayRelayUrl, displayRelayUrl,
readGroupMeta,
} from "src/domain" } from "src/domain"
import type { import type {
Channel, Channel,
@ -177,6 +182,7 @@ export const handles = withGetter(writable<Record<string, Handle>>({}))
export const zappers = withGetter(writable<Record<string, Zapper>>({})) export const zappers = withGetter(writable<Record<string, Zapper>>({}))
export const plaintext = withGetter(writable<Record<string, string>>({})) export const plaintext = withGetter(writable<Record<string, string>>({}))
export const anonymous = withGetter(writable<AnonymousUserState>({follows: [], relays: []})) export const anonymous = withGetter(writable<AnonymousUserState>({follows: [], relays: []}))
export const groupHints = withGetter(writable<Record<string, string[]>>({}))
export const groups = new CollectionStore<Group>("address") export const groups = new CollectionStore<Group>("address")
export const relays = new CollectionStore<RelayInfo>("url") export const relays = new CollectionStore<RelayInfo>("url")
@ -693,6 +699,18 @@ export const communityListsByPubkey = withGetter(
derived(communityLists, $ls => indexBy($l => $l.event.pubkey, $ls)), derived(communityLists, $ls => indexBy($l => $l.event.pubkey, $ls)),
) )
export const communityListsByAddress = derived(communityLists, $communityLists => {
const m = new Map<string, PublishedSingleton[]>()
for (const list of $communityLists) {
for (const a of getSingletonValues("a", list)) {
pushToMapKey(m, a, list)
}
}
return m
})
export const getCommunityList = (pk: string) => export const getCommunityList = (pk: string) =>
communityListsByPubkey.get().get(pk) as PublishedSingleton | undefined communityListsByPubkey.get().get(pk) as PublishedSingleton | undefined
@ -704,54 +722,55 @@ export const getCommunities = (pk: string) => getSingletonValues("a", getCommuni
export const deriveCommunities = (pk: string) => export const deriveCommunities = (pk: string) =>
derived(communityListsByPubkey, m => getSingletonValues("a", m.get(pk))) derived(communityListsByPubkey, m => getSingletonValues("a", m.get(pk)))
export const userFollowsByCommunity = derived(communityLists, $communityLists => {
const m = new Map<string, string[]>()
for (const list of $communityLists) {
for (const a of getSingletonValues("a", list)) {
pushToMapKey(m, a, list.event.pubkey)
}
}
return m
})
// Groups // Groups
export const deriveGroup = address => { export const groupMeta = deriveEventsMapped<PublishedGroupMeta>({
const {pubkey, identifier: id} = Address.from(address) filters: [{kinds: [GROUP, COMMUNITY]}],
itemToEvent: prop("event"),
eventToItem: readGroupMeta,
})
return groups.key(address).derived(defaultTo({id, pubkey, address})) export const groupMetaByAddress = withGetter(
} derived(groupMeta, $metas => indexBy($meta => getAddress($meta.event), $metas)),
)
export const searchGroups = derived( export const deriveGroupMeta = (address: string) =>
[groups.throttle(300), userFollowsByCommunity], derived(groupMetaByAddress, $m => $m.get(address))
([$groups, $userFollowsByCommunity]) => {
const options = $groups export const searchGroupMeta = derived(
.filter(group => !repository.deletes.has(group.address)) [groupMeta, communityListsByAddress, userFollows],
.map(group => ({group, score: $userFollowsByCommunity.get(group.address)?.length || 0})) ([$groupMeta, $communityListsByAddress, $userFollows]) => {
const options = $groupMeta.map(meta => {
const lists = $communityListsByAddress.get(getAddress(meta.event)) || []
const members = lists.map(l => l.event.pubkey)
const followedMembers = intersection(members, Array.from($userFollows))
return {...meta, score: followedMembers.length}
})
const fuse = new Fuse(options, { const fuse = new Fuse(options, {
keys: [{name: "group.id", weight: 0.2}, "group.meta.name", "group.meta.about"], keys: [{name: "identifier", weight: 0.2}, "name", {name: "about", weight: 0.5}],
threshold: 0.3, threshold: 0.3,
shouldSort: false, shouldSort: false,
includeScore: true, includeScore: true,
}) })
return (term: string) => { const sortFn = (r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100)
if (!term) {
return sortBy(item => -item.score, options).map(item => item.group)
}
return doPipe(fuse.search(term), [ return (term: string) =>
$results => term
sortBy((r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100), $results), ? sortBy(sortFn, fuse.search(term)).map((r: any) => r.item.meta)
$results => $results.map((r: any) => r.item.group), : sortBy(meta => -meta.score, options)
])
}
}, },
) )
// Legacy
export const deriveGroup = address => {
const {pubkey, identifier: id} = Address.from(address)
return groups.key(address).derived(defaultTo({id, pubkey, address}))
}
export const getRecipientKey = wrap => { export const getRecipientKey = wrap => {
const pubkey = Tags.fromEvent(wrap).values("p").first() const pubkey = Tags.fromEvent(wrap).values("p").first()
const sharedKey = groupSharedKeys.key(pubkey).get() const sharedKey = groupSharedKeys.key(pubkey).get()
@ -1140,20 +1159,21 @@ export const deriveUserRelayPolicy = url =>
// Relay selection // Relay selection
export const getGroupRelayUrls = address => { export const getGroupRelayUrls = address => {
const group = groups.key(address).get() const meta = groupMetaByAddress.get().get(address)
const keys = groupSharedKeys.get()
if (group?.relays) { if (meta?.relays) {
return group.relays return meta.relays.map(nth(1))
} }
const latestKey = last(sortBy(prop("created_at"), keys.filter(whereEq({group: address})))) const latestKey = last(
sortBy(prop("created_at"), get(groupSharedKeys).filter(whereEq({group: address}))),
)
if (latestKey?.hints) { if (latestKey?.hints) {
return latestKey.hints return latestKey.hints
} }
return [] return get(groupHints)[address] || []
} }
export const forceRelays = (relays: string[], forceRelays: string[]) => export const forceRelays = (relays: string[], forceRelays: string[]) =>
@ -1620,7 +1640,7 @@ export const publish = ({forcePlatform = true, ...request}: MyPublishRequest) =>
return pub return pub
} }
export const sign = (template, opts: {anonymous?: boolean; sk?: string}) => { export const sign = (template, opts: {anonymous?: boolean; sk?: string} = {}) => {
if (opts.anonymous) { if (opts.anonymous) {
return signer.get().signWithKey(template, generatePrivateKey()) return signer.get().signWithKey(template, generatePrivateKey())
} }

View File

@ -1,11 +0,0 @@
import {ellipsize} from "hurdak"
import {Address} from "@welshman/util"
import type {Group} from "src/engine/model"
export const getGroupNaddr = (group: Group) => Address.from(group.address, group.relays).toNaddr()
export const getGroupId = (group: Group) => group.address.split(":").slice(2).join(":")
export const getGroupName = (group: Group) => group.meta?.name || group.id || ""
export const displayGroup = (group: Group) => ellipsize(group ? getGroupName(group) : "No name", 60)

View File

@ -12,7 +12,6 @@ export * from "./nip59"
export * from "./signer" export * from "./signer"
export * from "./connect" export * from "./connect"
export * from "./events" export * from "./events"
export * from "./groups"
export const getConnect = memoize(session => new Connect(session)) export const getConnect = memoize(session => new Connect(session))

View File

@ -90,7 +90,7 @@
</div> </div>
<div <div
style={$$props.style || "min-height: 6rem"} style={$$props.style || "min-height: 6rem"}
class={cx($$props.class, "w-full min-w-0 outline-0")} class={cx($$props.class, "w-full min-w-0 whitespace-pre-line outline-0")}
{autofocus} {autofocus}
contenteditable contenteditable
bind:this={input} bind:this={input}