Compare commits

...

7 Commits

Author SHA1 Message Date
Jon Staab
e212297396 Bump welshman/net 2024-06-20 12:12:48 -07:00
Jon Staab
c9db37c92a Fix profile loading, improve feed list item updating 2024-06-20 11:08:13 -07:00
Jon Staab
148a63d95f Bump versions 2024-06-20 10:25:48 -07:00
Jon Staab
a5517f2eff Add feed favorites 2024-06-20 10:15:28 -07:00
Jon Staab
b92b0b71b5 Add global feeds 2024-06-19 14:52:29 -07:00
Jon Staab
c6cdcfb2f8 Fix some note rendering bugs, throw error when dufflepud is not configured 2024-06-19 11:16:30 -07:00
Jon Staab
c80988882b Pull group meta directly from events 2024-06-18 17:20:12 -07:00
52 changed files with 603 additions and 524 deletions

View File

@ -10,6 +10,8 @@
- [x] Fix several community and calendar related bugs - [x] Fix several community and calendar related bugs
- [x] Add reports using tagr-bot - [x] Add reports using tagr-bot
- [x] Open links to coracle in same tab - [x] Open links to coracle in same tab
- [x] Add global feeds
- [x] Add feed favorites
# 0.4.6 # 0.4.6

BIN
package-lock.json generated

Binary file not shown.

View File

@ -56,10 +56,10 @@
"@getalby/bitcoin-connect": "^3.2.2", "@getalby/bitcoin-connect": "^3.2.2",
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
"@welshman/content": "^0.0.5", "@welshman/content": "^0.0.5",
"@welshman/feeds": "^0.0.11", "@welshman/feeds": "^0.0.12",
"@welshman/lib": "^0.0.9", "@welshman/lib": "^0.0.10",
"@welshman/net": "^0.0.13", "@welshman/net": "^0.0.14",
"@welshman/util": "^0.0.14", "@welshman/util": "^0.0.15",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",

View File

@ -236,3 +236,9 @@ body,
.react-switch-bg { .react-switch-bg {
border: 1px solid var(--neutral-600); border: 1px solid var(--neutral-600);
} }
/* note content */
.note-content a {
text-decoration: underline;
}

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {NAMED_BOOKMARKS, toNostrURI, Address} from "@welshman/util" import {NAMED_BOOKMARKS, toNostrURI, Address} from "@welshman/util"
import {slide} from "src/util/transition" import {slide} from "src/util/transition"
import {boolCtrl} from "src/partials/utils" import {boolCtrl} from "src/partials/utils"
@ -9,8 +10,15 @@
import CopyValueSimple from "src/partials/CopyValueSimple.svelte" import CopyValueSimple from "src/partials/CopyValueSimple.svelte"
import FeedSummary from "src/app/shared/FeedSummary.svelte" import FeedSummary from "src/app/shared/FeedSummary.svelte"
import PersonBadgeSmall from "src/app/shared/PersonBadgeSmall.svelte" import PersonBadgeSmall from "src/app/shared/PersonBadgeSmall.svelte"
import {readFeed, readList, displayFeed, mapListToFeed} from "src/domain" import {readFeed, readList, displayFeed, mapListToFeed, getSingletonValues} from "src/domain"
import {repository} from "src/engine" import {
hints,
pubkey,
repository,
addFeedFavorite,
removeFeedFavorite,
userFeedFavorites,
} from "src/engine"
import {globalFeed} from "src/app/state" import {globalFeed} from "src/app/state"
import {router} from "src/app/util" import {router} from "src/app/util"
@ -19,14 +27,19 @@
const expandDefinition = boolCtrl() const expandDefinition = boolCtrl()
const event = repository.getEvent(address) const event = repository.getEvent(address)
const deleted = repository.isDeleted(event) const deleted = repository.isDeleted(event)
const naddr = Address.from(address, hints.Event(event).getUrls()).toNaddr()
const feed = address.startsWith(NAMED_BOOKMARKS) const feed = address.startsWith(NAMED_BOOKMARKS)
? mapListToFeed(readList(event)) ? mapListToFeed(readList(event))
: readFeed(event) : readFeed(event)
const toggleFavorite = () => (isFavorite ? removeFeedFavorite(address) : addFeedFavorite(address))
const loadFeed = () => { const loadFeed = () => {
globalFeed.set(feed) globalFeed.set(feed)
router.at("notes").push() router.at("notes").push()
} }
$: isFavorite = getSingletonValues("a", $userFeedFavorites).has(address)
</script> </script>
<Card class="flex gap-3"> <Card class="flex gap-3">
@ -69,7 +82,15 @@
<i class="fa fa-angle-right" /> <i class="fa fa-angle-right" />
{/if} {/if}
</div> </div>
<CopyValueSimple label="Feed address" value={toNostrURI(Address.from(address).toNaddr())} /> <div
class={cx("p-1 text-neutral-400 transition-colors hover:text-neutral-100", {
"cursor-pointer": feed.event.pubkey !== $pubkey,
"pointer-events-none opacity-25": feed.event.pubkey === $pubkey,
})}
on:click={toggleFavorite}>
<i class="fa fa-bookmark" class:text-accent={isFavorite} />
</div>
<CopyValueSimple label="Feed address" value={toNostrURI(naddr)} />
</div> </div>
</div> </div>
{#if $expandDefinition.enabled} {#if $expandDefinition.enabled}

View File

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import {equals} from "ramda" import {equals} from "ramda"
import {sortBy, uniqBy} from "@welshman/lib"
import {getAddress} from "@welshman/util"
import {isSearchFeed, makeSearchFeed, makeScopeFeed, Scope, getFeedArgs} from "@welshman/feeds" import {isSearchFeed, makeSearchFeed, makeScopeFeed, Scope, getFeedArgs} from "@welshman/feeds"
import {toSpliced} from "src/util/misc" import {toSpliced} from "src/util/misc"
import {boolCtrl} from "src/partials/utils" import {boolCtrl} from "src/partials/utils"
@ -13,8 +15,8 @@
import FeedForm from "src/app/shared/FeedForm.svelte" import FeedForm from "src/app/shared/FeedForm.svelte"
import {router} from "src/app/util" import {router} from "src/app/util"
import {globalFeed} from "src/app/state" import {globalFeed} from "src/app/state"
import {normalizeFeedDefinition, displayList, readFeed, makeFeed, displayFeed} from "src/domain" import {normalizeFeedDefinition, readFeed, makeFeed, displayFeed} from "src/domain"
import {userListFeeds, canSign, deleteEvent, userFeeds} from "src/engine" import {userListFeeds, canSign, deleteEvent, userFeeds, userFavoritedFeeds} from "src/engine"
export let feed export let feed
export let updateFeed export let updateFeed
@ -25,6 +27,10 @@
const listMenu = boolCtrl() const listMenu = boolCtrl()
const followsFeed = makeFeed({definition: normalizeFeedDefinition(makeScopeFeed(Scope.Follows))}) const followsFeed = makeFeed({definition: normalizeFeedDefinition(makeScopeFeed(Scope.Follows))})
const networkFeed = makeFeed({definition: normalizeFeedDefinition(makeScopeFeed(Scope.Network))}) const networkFeed = makeFeed({definition: normalizeFeedDefinition(makeScopeFeed(Scope.Network))})
const allFeeds = uniqBy(
feed => getAddress(feed.event),
sortBy(displayFeed, [...$userFeeds, ...$userListFeeds, ...$userFavoritedFeeds]),
)
const openForm = () => { const openForm = () => {
savePoint = {...feed} savePoint = {...feed}
@ -130,20 +136,13 @@
on:click={() => setFeed(networkFeed)}> on:click={() => setFeed(networkFeed)}>
Network Network
</MenuItem> </MenuItem>
{#each $userFeeds as feed} {#each allFeeds as feed}
<MenuItem <MenuItem
active={equals(feed.definition, $globalFeed.definition)} active={equals(feed.definition, $globalFeed.definition)}
on:click={() => setFeed(feed)}> on:click={() => setFeed(feed)}>
{displayFeed(feed)} {displayFeed(feed)}
</MenuItem> </MenuItem>
{/each} {/each}
{#each $userListFeeds as feed}
<MenuItem
active={equals(feed.definition, $globalFeed.definition)}
on:click={() => setFeed(feed)}>
{displayList(feed.list)}
</MenuItem>
{/each}
</div> </div>
{#if $canSign} {#if $canSign}
<div class="bg-neutral-900"> <div class="bg-neutral-900">

View File

@ -11,15 +11,18 @@
isRelayFeed, isRelayFeed,
isListFeed, isListFeed,
isDVMFeed, isDVMFeed,
isGlobalFeed,
makeListFeed, makeListFeed,
makeDVMFeed, makeDVMFeed,
makeScopeFeed, makeScopeFeed,
makeTagFeed, makeTagFeed,
makeRelayFeed, makeRelayFeed,
makeGlobalFeed,
Scope, Scope,
} from "@welshman/feeds" } from "@welshman/feeds"
import {toSpliced} from "src/util/misc" import {toSpliced} from "src/util/misc"
import Icon from "src/partials/Icon.svelte" import Icon from "src/partials/Icon.svelte"
import SelectButton from "src/partials/SelectButton.svelte"
import SelectTiles from "src/partials/SelectTiles.svelte" import SelectTiles from "src/partials/SelectTiles.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
@ -29,6 +32,7 @@
export let feed export let feed
enum FormType { enum FormType {
Global = "global",
Advanced = "advanced", Advanced = "advanced",
DVMs = "dvms", DVMs = "dvms",
Lists = "lists", Lists = "lists",
@ -60,25 +64,12 @@
const inferFormType = feed => { const inferFormType = feed => {
for (const subFeed of getFeedArgs(normalize(feed))) { for (const subFeed of getFeedArgs(normalize(feed))) {
if ([FeedType.Scope, FeedType.Author].includes(subFeed[0])) { if (isGlobalFeed(subFeed)) return FormType.Global
return FormType.People if (isScopeFeed(subFeed) || isAuthorFeed(subFeed)) return FormType.People
} if (isTagFeed(subFeed) && subFeed[1] === "#t") return FormType.Topics
if (isRelayFeed(subFeed)) return FormType.Relays
if (subFeed[0] === FeedType.Tag && subFeed[1] === "#t") { if (isListFeed(subFeed)) return FormType.Lists
return FormType.Topics if (isDVMFeed(subFeed)) return FormType.DVMs
}
if (subFeed[0] === FeedType.Relay) {
return FormType.Relays
}
if (subFeed[0] === FeedType.List) {
return FormType.Lists
}
if (subFeed[0] === FeedType.DVM) {
return FormType.DVMs
}
} }
return FormType.Advanced return FormType.Advanced
@ -96,7 +87,9 @@
// Remove filters directly related to the previous type // Remove filters directly related to the previous type
if (newFormType !== FormType.Advanced) { if (newFormType !== FormType.Advanced) {
if (formType === FormType.People) { if (formType === FormType.Global) {
removeSubFeed(isGlobalFeed)
} else if (formType === FormType.People) {
removeSubFeed(isPeopleFeed) removeSubFeed(isPeopleFeed)
} else if (formType === FormType.Topics) { } else if (formType === FormType.Topics) {
removeSubFeed(isTopicsFeed) removeSubFeed(isTopicsFeed)
@ -112,7 +105,9 @@
formType = newFormType formType = newFormType
// Add a default filter depending on the new form type // Add a default filter depending on the new form type
if (formType === FormType.People) { if (formType === FormType.Global) {
prependDefaultSubFeed(isGlobalFeed, makeGlobalFeed())
} else if (formType === FormType.People) {
prependDefaultSubFeed(isPeopleFeed, makeScopeFeed(Scope.Follows)) prependDefaultSubFeed(isPeopleFeed, makeScopeFeed(Scope.Follows))
} else if (formType === FormType.Topics) { } else if (formType === FormType.Topics) {
prependDefaultSubFeed(isTopicsFeed, makeTagFeed("#t")) prependDefaultSubFeed(isTopicsFeed, makeTagFeed("#t"))
@ -132,25 +127,55 @@
let formType = inferFormType(feed) let formType = inferFormType(feed)
$: formTypeOptions = [ $: formTypeOptions = [
FormType.Global,
FormType.People, FormType.People,
FormType.Topics, FormType.Topics,
FormType.Relays, FormType.Relays,
FormType.Lists, FormType.Lists,
FormType.DVMs, FormType.DVMs,
FormType.Advanced,
] ]
</script> </script>
<FlexColumn> <FlexColumn>
<Card class="-mb-8"> <Card>
<FlexColumn small> <FlexColumn small>
<span class="staatliches text-lg">Choose a feed type</span> <span class="staatliches text-lg">Choose a feed type</span>
<SelectButton
class="sm:hidden"
options={formTypeOptions}
onChange={onFormTypeChange}
value={formType}>
<div slot="item" class="flex items-center gap-2" let:option let:active>
{#if option === FormType.Global}
<i class="fa fa-earth-europe" /> Global
{:else if option === FormType.People}
<i class="fa fa-person" /> People
{:else if option === FormType.Topics}
<i class="fa fa-tags" /> Topics
{:else if option === FormType.Relays}
<i class="fa fa-server" /> Relays
{:else if option === FormType.Lists}
<i class="fa fa-bars-staggered" /> Lists
{:else if option === FormType.DVMs}
<i class="fa fa-circle-nodes" /> DVMs
{:else if option === FormType.Advanced}
<i class="fa fa-cogs" /> Advanced
{/if}
</div>
</SelectButton>
<SelectTiles <SelectTiles
class="grid-cols-2 xs:grid-cols-3 md:grid-cols-5" class="hidden grid-cols-4 sm:grid"
options={formTypeOptions} options={formTypeOptions}
onChange={onFormTypeChange} onChange={onFormTypeChange}
value={formType}> value={formType}>
<div slot="item" class="flex flex-col items-center" let:option let:active> <div slot="item" class="flex flex-col items-center" let:option let:active>
{#if option === FormType.People} {#if option === FormType.Global}
<span class="flex h-12 w-12 items-center justify-center" class:text-accent={active}>
<i class="fa fa-2xl fa-earth-europe" />
</span>
<span class="staatliches text-2xl">Global</span>
{:else if option === FormType.People}
<Icon icon="people-nearby" class="h-12 w-12" color={active ? "accent" : "tinted-800"} /> <Icon icon="people-nearby" class="h-12 w-12" color={active ? "accent" : "tinted-800"} />
<span class="staatliches text-2xl">People</span> <span class="staatliches text-2xl">People</span>
{:else if option === FormType.Topics} {:else if option === FormType.Topics}
@ -169,17 +194,25 @@
{:else if option === FormType.DVMs} {:else if option === FormType.DVMs}
<Icon icon="network" class="h-12 w-12" color={active ? "accent" : "tinted-800"} /> <Icon icon="network" class="h-12 w-12" color={active ? "accent" : "tinted-800"} />
<span class="staatliches text-2xl">DVMs</span> <span class="staatliches text-2xl">DVMs</span>
{:else if option === FormType.Advanced}
<span class="flex h-12 w-12 items-center justify-center" class:text-accent={active}>
<i class="fa fa-2xl fa-cogs" />
</span>
<span class="staatliches text-2xl">Advanced</span>
{/if} {/if}
</div> </div>
</SelectTiles> </SelectTiles>
<div
class="flex cursor-pointer items-center justify-end gap-2 text-neutral-500"
on:click={() => onFormTypeChange(FormType.Advanced)}>
<span class="staatliches underline">Advanced Mode</span>
</div>
</FlexColumn> </FlexColumn>
</Card> </Card>
<FlexColumn> <FlexColumn>
{#if formType === FormType.Global && feed.length === 2}
<Card class="flex gap-4 items-center">
<i class="fa fa-triangle-exclamation text-warning fa-xl" />
<p>
Be aware that feeds with no filters can result in obscene or otherwise objectionable content being displayed.
</p>
</Card>
{/if}
{#if formType === FormType.Advanced} {#if formType === FormType.Advanced}
<FeedFormAdvanced {feed} onChange={onFeedChange} /> <FeedFormAdvanced {feed} onChange={onFeedChange} />
{:else} {:else}

View File

@ -11,7 +11,15 @@
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import FeedField from "src/app/shared/FeedField.svelte" import FeedField from "src/app/shared/FeedField.svelte"
import {makeFeed, createFeed, editFeed, isMentionFeed, displayFeed} from "src/domain" import {makeFeed, createFeed, editFeed, isMentionFeed, displayFeed} from "src/domain"
import {canSign, deleteEvent, createAndPublish, loadPubkeys, hints} from "src/engine" import {
pubkey,
displayProfileByPubkey,
canSign,
deleteEvent,
createAndPublish,
loadPubkeys,
hints,
} from "src/engine"
export let feed export let feed
export let exit export let exit
@ -98,9 +106,18 @@
<Anchor underline on:click={openSave} class="text-neutral-400">Save this feed</Anchor> <Anchor underline on:click={openSave} class="text-neutral-400">Save this feed</Anchor>
</Card> </Card>
{:else if draft.event || draft.list} {:else if draft.event || draft.list}
{@const event = draft.event || draft.list.event}
<Card class="flex flex-col justify-between sm:flex-row"> <Card class="flex flex-col justify-between sm:flex-row">
<p>You are currently editing your {displayFeed(draft)} feed.</p> {#if event.pubkey === $pubkey}
<Anchor underline on:click={startClone} class="text-neutral-400"> <p>You are currently editing "{displayFeed(draft)}" feed.</p>
{:else}
<p>
You are currently cloning "{displayFeed(draft)}" by @{displayProfileByPubkey(
event.pubkey,
)}.
</p>
{/if}
<Anchor underline on:click={startClone} class="whitespace-nowrap text-neutral-400">
Create a new feed instead Create a new feed instead
</Anchor> </Anchor>
</Card> </Card>
@ -108,7 +125,7 @@
<Card class="flex flex-col justify-between sm:flex-row"> <Card class="flex flex-col justify-between sm:flex-row">
<p>You are currently creating a new feed.</p> <p>You are currently creating a new feed.</p>
<Anchor underline on:click={stopClone} class="text-neutral-400"> <Anchor underline on:click={stopClone} class="text-neutral-400">
Edit your {displayFeed(feed)} feed instead Edit "{displayFeed(feed)}" instead
</Anchor> </Anchor>
</Card> </Card>
{/if} {/if}

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import {tryCatch} from "@welshman/lib" import {tryCatch} from "@welshman/lib"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Field from "src/partials/Field.svelte"
import Textarea from "src/partials/Textarea.svelte" import Textarea from "src/partials/Textarea.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte"
export let feed export let feed
export let onChange export let onChange
@ -35,18 +35,19 @@
</script> </script>
<Card> <Card>
<Field label="Enter your custom feed below"> <FlexColumn>
<span class="staatliches text-lg">Enter your custom feed below</span>
<Textarea <Textarea
class="h-72 whitespace-pre-wrap" class="h-72 whitespace-pre-wrap"
value={json} value={json}
on:input={onInput} on:input={onInput}
on:focus={onFocus} on:focus={onFocus}
on:blur={onBlur} /> on:blur={onBlur} />
</Field> {#if !isValid && !isFocused}
{#if !isValid && !isFocused} <p>
<p> <i class="fa fa-triangle-exclamation" />
<i class="fa fa-triangle-exclamation" /> Your feed is currently invalid. Please double check that it is valid JSON.
Your feed is currently invalid. Please double check that it is valid JSON. </p>
</p> {/if}
{/if} </FlexColumn>
</Card> </Card>

View File

@ -2,6 +2,7 @@
import {toTitle} from "hurdak" import {toTitle} from "hurdak"
import { import {
getFeedArgs, getFeedArgs,
isGlobalFeed,
isCreatedAtFeed, isCreatedAtFeed,
isAuthorFeed, isAuthorFeed,
isKindFeed, isKindFeed,
@ -68,41 +69,48 @@
{#each subFeeds as subFeed, i} {#each subFeeds as subFeed, i}
{@const idx = i + 1} {@const idx = i + 1}
{@const change = f => onSubFeedChange(idx, f)} {@const change = f => onSubFeedChange(idx, f)}
<Card class="relative"> {@const canSave =
<FlexColumn> isAuthorFeed(subFeed) ||
<FlexColumn small> isRelayFeed(subFeed) ||
{#if isPeopleFeed(subFeed)} isTopicFeed(subFeed) ||
<FeedFormSectionPeople feed={subFeed} onChange={change} /> isMentionFeed(subFeed)}
{:else if isRelayFeed(subFeed)} {#if canSave || !isGlobalFeed(subFeed)}
<FeedFormSectionRelays feed={subFeed} onChange={change} /> <Card class="relative">
{:else if isTopicFeed(subFeed)} <FlexColumn>
<FeedFormSectionTopics feed={subFeed} onChange={change} /> <FlexColumn small>
{:else if isMentionFeed(subFeed)} {#if isPeopleFeed(subFeed)}
<FeedFormSectionMentions feed={subFeed} onChange={change} /> <FeedFormSectionPeople feed={subFeed} onChange={change} />
{:else if isKindFeed(subFeed)} {:else if isRelayFeed(subFeed)}
<FeedFormSectionKinds feed={subFeed} onChange={change} /> <FeedFormSectionRelays feed={subFeed} onChange={change} />
{:else if isCreatedAtFeed(subFeed)} {:else if isTopicFeed(subFeed)}
<FeedFormSectionCreatedAt feed={subFeed} onChange={change} /> <FeedFormSectionTopics feed={subFeed} onChange={change} />
{:else if isListFeed(subFeed)} {:else if isMentionFeed(subFeed)}
<FeedFormSectionList feed={subFeed} onChange={change} /> <FeedFormSectionMentions feed={subFeed} onChange={change} />
{:else if isDVMFeed(subFeed)} {:else if isKindFeed(subFeed)}
<FeedFormSectionDVM feed={subFeed} onChange={change} /> <FeedFormSectionKinds feed={subFeed} onChange={change} />
{:else} {:else if isCreatedAtFeed(subFeed)}
No support for editing {toTitle(subFeed[0])} filters. Click "Advanced" to edit manually. <FeedFormSectionCreatedAt feed={subFeed} onChange={change} />
{:else if isListFeed(subFeed)}
<FeedFormSectionList feed={subFeed} onChange={change} />
{:else if isDVMFeed(subFeed)}
<FeedFormSectionDVM feed={subFeed} onChange={change} />
{:else}
No support for editing {toTitle(subFeed[0])} filters. Click "Advanced" to edit manually.
{/if}
</FlexColumn>
{#if canSave}
<FeedFormSaveAsList feed={subFeed} onChange={change} />
{/if} {/if}
</FlexColumn> </FlexColumn>
{#if isAuthorFeed(subFeed) || isRelayFeed(subFeed) || isTopicFeed(subFeed) || isMentionFeed(subFeed)} {#if i > 0}
<FeedFormSaveAsList feed={subFeed} onChange={change} /> <div
class="absolute right-2 top-2 h-4 w-4 cursor-pointer"
on:click={() => onSubFeedRemove(idx)}>
<i class="fa fa-times" />
</div>
{/if} {/if}
</FlexColumn> </Card>
{#if i > 0} {/if}
<div
class="absolute right-2 top-2 h-4 w-4 cursor-pointer"
on:click={() => onSubFeedRemove(idx)}>
<i class="fa fa-times" />
</div>
{/if}
</Card>
{/each} {/each}
{/key} {/key}

View File

@ -21,7 +21,7 @@
$: addresses = feed.slice(1).flatMap(it => it.addresses) $: addresses = feed.slice(1).flatMap(it => it.addresses)
</script> </script>
<span>Which lists would you like to use?</span> <span class="staatliches text-lg">Which lists would you like to use?</span>
<SearchSelect <SearchSelect
multiple multiple
value={addresses} value={addresses}

View File

@ -23,7 +23,7 @@
$: scopes = isScopeFeed(feed) ? feed.slice(1) : ["custom"] $: scopes = isScopeFeed(feed) ? feed.slice(1) : ["custom"]
</script> </script>
<span>Which authors would you like to see?</span> <span class="staatliches text-lg">Which authors would you like to see?</span>
<SelectButton <SelectButton
multiple multiple
value={scopes} value={scopes}

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,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

@ -2,17 +2,19 @@
import {ellipsize} from "hurdak" import {ellipsize} from "hurdak"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {remove, intersection} 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, userFollows, communityListsByAddress, 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(communityListsByAddress, $m => { const members = derived(communityListsByAddress, $m => {
const allMembers = $m.get(address)?.map(l => l.event.pubkey) || [] const allMembers = $m.get(address)?.map(l => l.event.pubkey) || []
const otherMembers = remove($pubkey, allMembers) const otherMembers = remove($pubkey, allMembers)
@ -37,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,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

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {without} from "ramda"
import { import {
parse, parse,
truncate, truncate,
@ -16,7 +15,6 @@
isAddress, isAddress,
isNewline, isNewline,
} from "@welshman/content" } from "@welshman/content"
import MediaSet from "src/partials/MediaSet.svelte"
import QRCode from "src/partials/QRCode.svelte" import QRCode from "src/partials/QRCode.svelte"
import NoteContentNewline from "src/app/shared/NoteContentNewline.svelte" import NoteContentNewline from "src/app/shared/NoteContentNewline.svelte"
import NoteContentEllipsis from "src/app/shared/NoteContentEllipsis.svelte" import NoteContentEllipsis from "src/app/shared/NoteContentEllipsis.svelte"
@ -44,16 +42,15 @@
const isBoundary = i => { const isBoundary = i => {
const parsed = shortContent[i] const parsed = shortContent[i]
if (!parsed || isBoundary(parsed)) return true if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return parsed.value.match(/^\s+$/) if (isText(parsed)) return parsed.value.match(/^\s+$/)
return false return false
} }
const isStartOrEnd = i => Boolean(isBoundary(i - 1) || isBoundary(i + 1)) const isStartAndEnd = i => Boolean(isBoundary(i - 1) && isBoundary(i + 1))
const getLinks = content => const isStartOrEnd = i => Boolean(isBoundary(i - 1) || isBoundary(i + 1))
content.filter(x => isLink(x) && x.value.isMedia).map(x => x.value.url.toString())
$: shortContent = showEntire $: shortContent = showEntire
? fullContent ? fullContent
@ -66,13 +63,11 @@
}, },
) )
$: links = getLinks(shortContent)
$: extraLinks = without(links, getLinks(fullContent))
$: ellipsize = expandable && shortContent.find(isEllipsis) $: ellipsize = expandable && shortContent.find(isEllipsis)
</script> </script>
<div <div
class="flex flex-col gap-2 overflow-hidden text-ellipsis" class="note-content flex flex-col gap-2 overflow-hidden text-ellipsis"
style={ellipsize && "mask-image: linear-gradient(0deg, transparent 0px, black 100px)"}> style={ellipsize && "mask-image: linear-gradient(0deg, transparent 0px, black 100px)"}>
<div> <div>
{#each shortContent as parsed, i} {#each shortContent as parsed, i}
@ -91,7 +86,7 @@
<QRCode copyOnClick code={parsed.value} /> <QRCode copyOnClick code={parsed.value} />
</div> </div>
{:else if isLink(parsed)} {:else if isLink(parsed)}
<NoteContentLink value={parsed.value} showMedia={showMedia && isStartOrEnd(i)} /> <NoteContentLink value={parsed.value} showMedia={showMedia && isStartAndEnd(i)} />
{:else if isProfile(parsed)} {:else if isProfile(parsed)}
<PersonLink pubkey={parsed.value.pubkey} /> <PersonLink pubkey={parsed.value.pubkey} />
{:else if (isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 2} {:else if (isEvent(parsed) || isAddress(parsed)) && isStartOrEnd(i) && depth < 2}
@ -100,16 +95,11 @@
<slot name="note-content" {quote} /> <slot name="note-content" {quote} />
</div> </div>
</NoteContentQuote> </NoteContentQuote>
{:else if !expandable && isEllipsis(parsed)}
{@html renderParsed(parsed)}
{:else} {:else}
{@html renderParsed(parsed)} {@html renderParsed(parsed)}
{/if} {/if}
{/each} {/each}
</div> </div>
{#if showMedia && extraLinks.length > 0}
<MediaSet links={extraLinks} />
{/if}
</div> </div>
{#if ellipsize} {#if ellipsize}

View File

@ -10,6 +10,8 @@
const url = value.url.toString() const url = value.url.toString()
const coracleRegexp = /^(https?:\/\/)?(app\.)?coracle.social/
const close = () => { const close = () => {
hidden = true hidden = true
} }
@ -17,18 +19,16 @@
let hidden = false let hidden = false
</script> </script>
{#if url.includes('coracle.social/')} {#if url.match(coracleRegexp)}
<Anchor <Anchor
modal modal
stopPropagation stopPropagation
class="overflow-hidden text-ellipsis whitespace-nowrap underline" class="overflow-hidden text-ellipsis whitespace-nowrap underline"
href={url.replace(/(https?:\/\/)?(app\.)?coracle.social/, '')}> href={url.replace(coracleRegexp, '')}>
{displayUrl(url)} {displayUrl(url)}
</Anchor> </Anchor>
{:else if showMedia && value.isMedia && !hidden} {:else if showMedia && !hidden}
<div class="py-2"> <Media url={url} onClose={close} />
<Media url={url} onClose={close} />
</div>
{:else if isShareableRelayUrl(url)} {:else if isShareableRelayUrl(url)}
<Anchor <Anchor
modal modal

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import {derived} from "svelte/store"
import {uniq} from "@welshman/lib" import {uniq} from "@welshman/lib"
import {parseAnything} from "src/util/nostr" import {parseAnything} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import SearchSelect from "src/partials/SearchSelect.svelte" import SearchSelect from "src/partials/SearchSelect.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte" import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {profileSearch, createPeopleLoader} from "src/engine" import {profileSearch, loadPubkeyProfiles, createPeopleLoader} from "src/engine"
export let value export let value
export let multiple = false export let multiple = false
@ -16,27 +17,33 @@
const {loading, load} = createPeopleLoader() const {loading, load} = createPeopleLoader()
const search = term => { const search = derived(profileSearch, $profileSearch => {
load(term) return term => {
load(term)
parseAnything(term).then(result => { parseAnything(term).then(result => {
if (result?.type === "npub") { if (result?.type === "npub") {
value = uniq(value.concat(result.data)) loadPubkeyProfiles([result.data])
input.clearTerm() value = uniq(value.concat(result.data))
} input.clearTerm()
onChange(value)
}
if (result?.type === "nprofile") { if (result?.type === "nprofile") {
value = uniq(value.concat(result.data.pubkey)) loadPubkeyProfiles([result.data.pubkey])
input.clearTerm() value = uniq(value.concat(result.data.pubkey))
} input.clearTerm()
}) onChange(value)
}
})
return $profileSearch.searchValues(term) return $profileSearch.searchValues(term)
} }
})
</script> </script>
<SearchSelect <SearchSelect
{search} search={$search}
{onChange} {onChange}
{multiple} {multiple}
{autofocus} {autofocus}

View File

@ -96,7 +96,7 @@
<p class="mt-4 text-lg">The following relays are still pending:</p> <p class="mt-4 text-lg">The following relays are still pending:</p>
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
{#each pending as url} {#each pending as url}
<RelayCard hideActions {url} /> <RelayCard hideDescription hideActions {url} />
{/each} {/each}
</div> </div>
{/if} {/if}
@ -104,7 +104,7 @@
<p class="mt-4 text-lg">The following relays accepted your note:</p> <p class="mt-4 text-lg">The following relays accepted your note:</p>
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
{#each success as url} {#each success as url}
<RelayCard hideActions {url} /> <RelayCard hideDescription hideActions {url} />
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -18,6 +18,7 @@
export let claim = null export let claim = null
export let ratings = null export let ratings = null
export let showStatus = false export let showStatus = false
export let hideDescription = false
export let hideRatingsCount = false export let hideRatingsCount = false
export let hideActions = false export let hideActions = false
export let showControls = false export let showControls = false
@ -64,30 +65,32 @@
</slot> </slot>
{/if} {/if}
</div> </div>
<slot name="description"> {#if !hideDescription}
{#if $relay.description} <slot name="description">
<p>{$relay.description}</p> {#if $relay.description}
<p>{$relay.description}</p>
{/if}
</slot>
{#if !isNil($relay.count)}
<span class="flex items-center gap-1 text-sm text-neutral-400">
{#if $relay.contact}
<Anchor external underline href={$relay.contact}>{displayUrl($relay.contact)}</Anchor>
&bull;
{/if}
{#if $relay.supported_nips}
<Popover>
<span slot="trigger" class="cursor-pointer underline">
{$relay.supported_nips.length} NIPs
</span>
<span slot="tooltip">
NIPs supported: {$relay.supported_nips.join(", ")}
</span>
</Popover>
&bull;
{/if}
Seen {quantify($relay.count || 0, "time")}
</span>
{/if} {/if}
</slot>
{#if !isNil($relay.count)}
<span class="flex items-center gap-1 text-sm text-neutral-400">
{#if $relay.contact}
<Anchor external underline href={$relay.contact}>{displayUrl($relay.contact)}</Anchor>
&bull;
{/if}
{#if $relay.supported_nips}
<Popover>
<span slot="trigger" class="cursor-pointer underline">
{$relay.supported_nips.length} NIPs
</span>
<span slot="tooltip">
NIPs supported: {$relay.supported_nips.join(", ")}
</span>
</Popover>
&bull;
{/if}
Seen {quantify($relay.count || 0, "time")}
</span>
{/if} {/if}
{#if showControls && $canSign} {#if showControls && $canSign}
<div class="-mx-6 my-1 h-px bg-tinted-700" /> <div class="-mx-6 my-1 h-px bg-tinted-700" />

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {sortBy, uniqBy} from "@welshman/lib"
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {onMount} from "svelte" import {onMount} from "svelte"
import {createScroller} from "src/util/misc" import {createScroller} from "src/util/misc"
@ -8,15 +9,18 @@
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
import FeedCard from "src/app/shared/FeedCard.svelte" import FeedCard from "src/app/shared/FeedCard.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {displayFeed} from "src/domain"
import { import {
pubkey,
userFeeds, userFeeds,
feedSearch, feedSearch,
userListFeeds, userListFeeds,
loadPubkeyFeeds, loadPubkeyFeeds,
userFavoritedFeeds,
userFollows, userFollows,
} from "src/engine" } from "src/engine"
const favoritedFeeds = $userFavoritedFeeds
const createFeed = () => router.at("feeds/create").open() const createFeed = () => router.at("feeds/create").open()
const editFeed = address => router.at("feeds").of(address).open() const editFeed = address => router.at("feeds").of(address).open()
@ -29,6 +33,13 @@
let limit = 20 let limit = 20
let element let element
$: allUserFeeds = [...$userFeeds, ...$userListFeeds]
$: feeds = uniqBy(
feed => getAddress(feed.event),
sortBy(displayFeed, [...allUserFeeds, ...favoritedFeeds]),
)
loadPubkeyFeeds(Array.from($userFollows)) loadPubkeyFeeds(Array.from($userFollows))
onMount(() => { onMount(() => {
@ -48,7 +59,7 @@
<i class="fa fa-plus" /> Feed <i class="fa fa-plus" /> Feed
</Anchor> </Anchor>
</div> </div>
{#each $userFeeds as feed (getAddress(feed.event))} {#each feeds as feed (feed.event.id)}
{@const address = getAddress(feed.event)} {@const address = getAddress(feed.event)}
<div in:fly={{y: 20}}> <div in:fly={{y: 20}}>
<FeedCard {address}> <FeedCard {address}>
@ -60,7 +71,7 @@
</FeedCard> </FeedCard>
</div> </div>
{/each} {/each}
{#each $userListFeeds as feed (getAddress(feed.list.event))} {#each $userListFeeds as feed (feed.list.event.id)}
{@const address = getAddress(feed.list.event)} {@const address = getAddress(feed.list.event)}
<div in:fly={{y: 20}}> <div in:fly={{y: 20}}>
<FeedCard {address}> <FeedCard {address}>
@ -85,7 +96,7 @@
</Input> </Input>
{#each $feedSearch {#each $feedSearch
.searchValues(q) .searchValues(q)
.filter(address => !address.includes($pubkey)) .filter(address => !feeds.find(feed => getAddress(feed.event) === address))
.slice(0, limit) as address (address)} .slice(0, limit) as address (address)}
<FeedCard {address} /> <FeedCard {address} />
{/each} {/each}

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)
@ -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,8 +1,9 @@
<script> <script>
import {onMount} from "svelte" import {onMount} from "svelte"
import {filter, assoc} from "ramda" import {filter, reject, assoc} from "ramda"
import {derived} from "svelte/store"
import {now, shuffle} from "@welshman/lib" import {now, shuffle} from "@welshman/lib"
import {GROUP, COMMUNITY, getIdFilters} from "@welshman/util" 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"
@ -12,40 +13,37 @@
load, load,
hints, hints,
groups, groups,
repository,
loadGiftWraps, loadGiftWraps,
loadGroupMessages, loadGroupMessages,
deriveIsGroupMember, deriveIsGroupMember,
updateCurrentSession, updateCurrentSession,
communityListsByAddress, communityListsByAddress,
searchGroups, 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 loader = loadGiftWraps() const loader = loadGiftWraps()
const scroller = createScroller(loadMore, {element}) const scroller = createScroller(loadMore, {element})
const communityAddrs = Array.from($communityListsByAddress.keys()) const communityAddrs = Array.from($communityListsByAddress.keys()).filter(
.filter(a => !groups.key(a).get()?.meta) a => !groups.key(a).get()?.meta,
)
updateCurrentSession(assoc("groups_last_synced", now())) updateCurrentSession(assoc("groups_last_synced", now()))
@ -81,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}
@ -90,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>

View File

@ -33,7 +33,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="fa fa-list fa-lg" /> <i class="fa fa-list fa-lg" />
<h2 class="staatliches text-2xl">Your feeds</h2> <h2 class="staatliches text-2xl">Your lists</h2>
</div> </div>
<Anchor button accent on:click={createList}> <Anchor button accent on:click={createList}>
<i class="fa fa-plus" /> List <i class="fa fa-plus" /> List

View File

@ -5,7 +5,7 @@
import {join, whereEq, identity} from "ramda" import {join, whereEq, identity} from "ramda"
import {throttle, commaFormat, toTitle, switcherFn} from "hurdak" import {throttle, commaFormat, toTitle, switcherFn} from "hurdak"
import {now, writable} from "@welshman/lib" import {now, writable} from "@welshman/lib"
import {createEvent, Tags} from "@welshman/util" import {createEvent} from "@welshman/util"
import {currencyOptions} from "src/util/i18n" import {currencyOptions} from "src/util/i18n"
import {dateToSeconds} from "src/util/misc" import {dateToSeconds} from "src/util/misc"
import {showWarning, showPublishInfo} from "src/partials/Toast.svelte" import {showWarning, showPublishInfo} from "src/partials/Toast.svelte"
@ -44,11 +44,7 @@
export let group = null export let group = null
export let initialValues = {} export let initialValues = {}
const defaultGroups = $env.FORCE_GROUP const defaultGroups = $env.FORCE_GROUP ? [$env.FORCE_GROUP] : [group].filter(identity)
? [$env.FORCE_GROUP]
: quote
? Tags.fromEvent(quote).context().values().valueOf()
: [group].filter(identity)
let images, compose let images, compose
let charCount = 0 let charCount = 0

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

@ -10,6 +10,7 @@ import {
Address, Address,
isSignedEvent, isSignedEvent,
normalizeRelayUrl, normalizeRelayUrl,
FEEDS,
FOLLOWS, FOLLOWS,
RELAYS, RELAYS,
PROFILE, PROFILE,
@ -51,7 +52,6 @@ import {
loadOne, loadOne,
createAndPublish, createAndPublish,
deriveAdminKeyForGroup, deriveAdminKeyForGroup,
deriveGroup,
deriveIsGroupMember, deriveIsGroupMember,
deriveSharedKeyForGroup, deriveSharedKeyForGroup,
displayProfileByPubkey, displayProfileByPubkey,
@ -250,7 +250,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 +258,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 +267,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:
@ -485,7 +486,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 +502,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 +536,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, relays) 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, relays) ? publishAsGroupAdminPublicly(address, template, meta.relays)
: publishAsGroupAdminPrivately(address, template, relays) : publishAsGroupAdminPrivately(address, template, meta.relays)
} }
export const deleteGroupMeta = address => export const deleteGroupMeta = address =>
@ -739,6 +744,12 @@ export const unmuteNote = (id: string) => updateSingleton(MUTES, tags => reject(
export const muteNote = (id: string) => updateSingleton(MUTES, tags => append(tags, ["e", id])) export const muteNote = (id: string) => updateSingleton(MUTES, tags => append(tags, ["e", id]))
export const removeFeedFavorite = (address: string) =>
updateSingleton(FEEDS, tags => reject(nthEq(1, address), tags))
export const addFeedFavorite = (address: string) =>
updateSingleton(FEEDS, tags => append(tags, ["a", address]))
// Relays // Relays
export const requestRelayAccess = async (url: string, claim: string, sk?: string) => export const requestRelayAccess = async (url: string, claim: string, sk?: string) =>

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.banner,
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

@ -90,7 +90,7 @@ export const deriveEventsMapped = <T>({
if (dirty) { if (dirty) {
if (debug) { if (debug) {
if (new Set(data.map(item => itemToEvent(item).id)).size < data.length) { if (new Set(data.map(item => itemToEvent(item).id)).size < data.length) {
console.error(`Duplicate records found:`, copy, data, updates) console.error(`Duplicate records found:`, copy, [...data], updates)
} }
} }

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"
@ -140,9 +141,14 @@ 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
const group = groups.key(addr).get()
if (!group?.meta || attempts === 0) { if (attempts > 0) {
continue
}
const meta = get(deriveGroupMeta(addr))
if (!meta) {
stale.add(addr) stale.add(addr)
} }

View File

@ -8,6 +8,7 @@ import {
NAMED_BOOKMARKS, NAMED_BOOKMARKS,
COMMUNITIES, COMMUNITIES,
FEED, FEED,
FEEDS,
MUTES, MUTES,
FOLLOWS, FOLLOWS,
APP_DATA, APP_DATA,
@ -55,7 +56,7 @@ const getFiltersForKey = (key: string, authors: string[]) => {
return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION, COMMUNITIES]}] return [{authors, kinds: [PROFILE, FOLLOWS, HANDLER_INFORMATION, COMMUNITIES]}]
case "pubkey/user": case "pubkey/user":
return [ return [
{authors, kinds: [PROFILE, RELAYS, MUTES, FOLLOWS, COMMUNITIES, APP_DATA]}, {authors, kinds: [PROFILE, RELAYS, MUTES, FOLLOWS, COMMUNITIES, FEEDS]},
{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,
@ -43,6 +43,9 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import { import {
WRAP, WRAP,
FEEDS,
COMMUNITY,
GROUP,
WRAP_NIP04, WRAP_NIP04,
COMMUNITIES, COMMUNITIES,
READ_RECEIPT, READ_RECEIPT,
@ -103,6 +106,7 @@ import type {
PublishedListFeed, PublishedListFeed,
PublishedSingleton, PublishedSingleton,
PublishedList, PublishedList,
PublishedGroupMeta,
RelayPolicy, RelayPolicy,
Handle, Handle,
} from "src/domain" } from "src/domain"
@ -130,6 +134,7 @@ import {
makeRelayPolicy, makeRelayPolicy,
filterRelaysByNip, filterRelaysByNip,
displayRelayUrl, displayRelayUrl,
readGroupMeta,
} from "src/domain" } from "src/domain"
import type { import type {
Channel, Channel,
@ -178,6 +183,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")
@ -250,7 +256,15 @@ export const imgproxy = (url: string, {w = 640, h = 1024} = {}) => {
} }
} }
export const dufflepud = (path: string) => `${getSetting("dufflepud_url")}/${path}` export const dufflepud = (path: string) => {
const base = getSetting("dufflepud_url")
if (!base) {
throw new Error("Dufflepud is not enabled")
}
return `${base}/${path}`
}
export const session = new Derived( export const session = new Derived(
[pubkey, sessions], [pubkey, sessions],
@ -719,46 +733,53 @@ export const deriveCommunities = (pk: string) =>
// 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), communityListsByAddress, userFollows], derived(groupMetaByAddress, $m => $m.get(address))
([$groups, $communityListsByAddress, $userFollows]) => {
const options = $groups
.filter(group => !repository.deletes.has(group.address))
.map(group => {
const lists = $communityListsByAddress.get(group.address) || []
const members = lists.map(l => l.event.pubkey)
const followedMembers = intersection(members, $userFollows)
return {group, score: followedMembers.length} export const searchGroupMeta = derived(
}) [groupMeta, communityListsByAddress, userFollows],
([$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()
@ -943,8 +964,7 @@ export const groupNotifications = new Derived(
const $isEventMuted = isEventMuted.get() const $isEventMuted = isEventMuted.get()
const shouldSkip = e => { const shouldSkip = e => {
const tags = Tags.fromEvent(e) const context = e.tags.filter(t => t[0] === "a")
const context = tags.context().values().valueOf()
return ( return (
!context.some(a => addresses.has(a)) || !context.some(a => addresses.has(a)) ||
@ -952,7 +972,7 @@ export const groupNotifications = new Derived(
!noteKinds.includes(e.kind) || !noteKinds.includes(e.kind) ||
e.pubkey === $session.pubkey || e.pubkey === $session.pubkey ||
// Skip mentions since they're covered in normal notifications // Skip mentions since they're covered in normal notifications
tags.values("p").has($session.pubkey) || e.tags.find(t => t[0] === "p" && t[1] === $session.pubkey) ||
$isEventMuted(e) $isEventMuted(e)
) )
} }
@ -1147,20 +1167,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[]) =>
@ -1257,15 +1278,36 @@ export const listSearch = derived(lists, $lists => new ListSearch($lists))
export const feeds = deriveEventsMapped<PublishedFeed>({ export const feeds = deriveEventsMapped<PublishedFeed>({
filters: [{kinds: [FEED]}], filters: [{kinds: [FEED]}],
eventToItem: readFeed,
itemToEvent: prop("event"), itemToEvent: prop("event"),
eventToItem: readFeed,
}) })
export const userFeeds = derived([feeds, pubkey], ([$feeds, $pubkey]: [PublishedFeed[], string]) => export const userFeeds = derived([feeds, pubkey], ([$feeds, $pubkey]: [PublishedFeed[], string]) =>
sortBy( $feeds.filter(feed => feed.event.pubkey === $pubkey),
f => f.title.toLowerCase(), )
$feeds.filter(feed => feed.event.pubkey === $pubkey),
), export const feedFavorites = deriveEventsMapped<PublishedSingleton>({
filters: [{kinds: [FEEDS]}],
itemToEvent: prop("event"),
eventToItem: event =>
readSingleton(
asDecryptedEvent(event, {
content: getPlaintext(event),
}),
),
})
export const userFeedFavorites = derived(
[feedFavorites, pubkey],
([$singletons, $pubkey]: [PublishedSingleton[], string]) =>
$singletons.find(singleton => singleton.event.pubkey === $pubkey),
)
export const userFavoritedFeeds = derived(userFeedFavorites, $singleton =>
Array.from(getSingletonValues("a", $singleton))
.map(repository.getEvent)
.filter(identity)
.map(readFeed),
) )
export const feedSearch = derived(feeds, $feeds => new FeedSearch($feeds)) export const feedSearch = derived(feeds, $feeds => new FeedSearch($feeds))
@ -1495,6 +1537,7 @@ export const onAuth = async (url, challenge) => {
export type MySubscribeRequest = SubscribeRequest & { export type MySubscribeRequest = SubscribeRequest & {
onEvent?: (event: TrustedEvent) => void onEvent?: (event: TrustedEvent) => void
onEose?: (url: string) => void
onComplete?: () => void onComplete?: () => void
skipCache?: boolean skipCache?: boolean
forcePlatform?: boolean forcePlatform?: boolean
@ -1539,6 +1582,10 @@ export const subscribe = ({forcePlatform = true, ...request}: MySubscribeRequest
projections.push(await ensureUnwrapped(event)) projections.push(await ensureUnwrapped(event))
}) })
if (request.onEose) {
sub.emitter.on("eose", request.onEose)
}
if (request.onComplete) { if (request.onComplete) {
sub.emitter.on("complete", request.onComplete) sub.emitter.on("complete", request.onComplete)
} }
@ -1577,7 +1624,14 @@ export const subscribePersistent = (request: MySubscribeRequest) => {
export const LOAD_OPTS = {timeout: 3000, closeOnEose: true} export const LOAD_OPTS = {timeout: 3000, closeOnEose: true}
export const load = (request: MySubscribeRequest) => subscribe({...request, ...LOAD_OPTS}).result export const load = (request: MySubscribeRequest) =>
new Promise(resolve => {
const events = []
const sub = subscribe({...request, ...LOAD_OPTS})
sub.emitter.on("event", (url: string, event: TrustedEvent) => events.push(event))
sub.emitter.on("complete", (url: string) => resolve(events))
})
export const loadOne = (request: MySubscribeRequest) => export const loadOne = (request: MySubscribeRequest) =>
new Promise<TrustedEvent | null>(resolve => { new Promise<TrustedEvent | null>(resolve => {
@ -1986,16 +2040,19 @@ class IndexedDBAdapter {
const removedRecords = prev.filter(r => !currentIds.has(r[key])) const removedRecords = prev.filter(r => !currentIds.has(r[key]))
if (newRecords.length > 0) { if (newRecords.length > 0) {
console.log("putting", name, newRecords.length, current.length)
await storage.bulkPut(name, newRecords) await storage.bulkPut(name, newRecords)
} }
if (removedRecords.length > 0) { if (removedRecords.length > 0) {
console.trace("deleting", name, removedRecords.length, current.length)
await storage.bulkDelete(name, removedRecords.map(prop(key))) await storage.bulkDelete(name, removedRecords.map(prop(key)))
} }
// If we have much more than our limit, prune our store. This will get persisted // If we have much more than our limit, prune our store. This will get persisted
// the next time around. // the next time around.
if (current.length > limit * 1.5) { if (current.length > limit * 1.5) {
console.log("pruning", name, current.length)
set((sort ? sort(current) : current).slice(0, limit)) set((sort ? sort(current) : current).slice(0, limit))
} }

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

@ -18,8 +18,8 @@
const loadPreview = async () => { const loadPreview = async () => {
const json = await Fetch.postJson(dufflepud("link/preview"), {url}) const json = await Fetch.postJson(dufflepud("link/preview"), {url})
if (!json.title && !json.image) { if (!json?.title && !json?.image) {
throw new Error("Unable to load preview") throw new Error("Failed to load link preview")
} }
return json return json
@ -81,7 +81,7 @@
</div> </div>
{/if} {/if}
{:catch} {:catch}
<p class="mb-1 px-12 py-24 text-center text-neutral-600"> <p class="mb-1 p-12 text-center text-neutral-600">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}

View File

@ -1,31 +0,0 @@
<script lang="ts">
import Media from "src/partials/Media.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Modal from "src/partials/Modal.svelte"
export let links
let showModal = false
const openModal = () => {
showModal = true
}
const closeModal = () => {
showModal = false
}
</script>
<div class="my-8 flex justify-center">
<Anchor button on:click={openModal}>
<i class="fa fa-plus" /> Show all {links.length} link previews
</Anchor>
</div>
{#if showModal}
<Modal onEscape={closeModal}>
{#each links as url}
<Media {url} />
{/each}
</Modal>
{/if}

View File

@ -5,16 +5,18 @@
export let displayOption = x => x export let displayOption = x => x
const getClassName = active => const getClassName = active =>
cx("px-4 h-7 transition-all rounded-full mr-2 mb-2 inline-flex items-center", { cx("px-4 h-6 transition-all rounded-full mr-2 mb-2 inline-block items-center", {
"bg-neutral-900 dark:bg-tinted-100 text-accent": active, "bg-neutral-900 dark:bg-tinted-100 text-accent": active,
"bg-neutral-900 text-neutral-400": !active, "bg-neutral-900 text-neutral-400": !active,
}) })
</script> </script>
<div class="-mb-2 inline-block"> <div class={cx($$props.class, "-mb-2 inline-block")}>
<SelectList {...$$props} class="staatliches inline-flex"> <SelectList {...$$props} optionClass="staatliches inline-block">
<div slot="item" let:i let:active let:option class={getClassName(active)}> <div slot="item" let:i let:active let:option class={getClassName(active)}>
{displayOption(option)} <slot name="item" {option} {active}>
{displayOption(option)}
</slot>
</div> </div>
</SelectList> </SelectList>
</div> </div>

View File

@ -6,6 +6,7 @@
export let onChange = null export let onChange = null
export let disabled = false export let disabled = false
export let multiple = false export let multiple = false
export let optionClass = ""
const onClick = option => { const onClick = option => {
if (multiple) { if (multiple) {
@ -24,7 +25,7 @@
class:opacity-75={disabled} class:opacity-75={disabled}
class:cursor-pointer={!disabled}> class:cursor-pointer={!disabled}>
{#each options as option, i} {#each options as option, i}
<div on:click={() => onClick(option)}> <div class={optionClass} on:click={() => onClick(option)}>
<slot <slot
name="item" name="item"
{i} {i}