Compare commits

..

7 Commits

Author SHA1 Message Date
Jon Staab
1408f782f1 Toggle nip44 messages based on 10050 signaling 2024-06-20 16:59:19 -07:00
Jon Staab
b1f2218e81 Add support for kind 10050 relay lists 2024-06-20 16:34:43 -07:00
Jon Staab
282a80cdea Add zap the developer 2024-06-20 14:10:39 -07:00
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
25 changed files with 428 additions and 224 deletions

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
# 0.4.8
- [x] Add support for kind 10050 relay lists
- [x] Toggle nip44 messages based on 10050 signaling
# 0.4.7 # 0.4.7
- [x] Show toast when offline - [x] Show toast when offline
@ -11,6 +16,7 @@
- [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 global feeds
- [x] Add feed favorites
# 0.4.6 # 0.4.6

BIN
package-lock.json generated

Binary file not shown.

View File

@ -57,9 +57,9 @@
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
"@welshman/content": "^0.0.5", "@welshman/content": "^0.0.5",
"@welshman/feeds": "^0.0.12", "@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

@ -319,6 +319,7 @@
required: ["splits"], required: ["splits"],
serializers: { serializers: {
eid: asNote, eid: asNote,
amount: asJson("amount"),
splits: asJson("splits"), splits: asJson("splits"),
anonymous: asJson("anonymous"), anonymous: asJson("anonymous"),
}, },

View File

@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {writable} from "@welshman/lib" import {writable, hash} from "@welshman/lib"
import {createScroller, synced} from "src/util/misc" import {createScroller, synced} from "src/util/misc"
import {fly, fade} from "src/util/transition" import {fly, fade} from "src/util/transition"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import Note from "src/app/shared/Note.svelte" import Note from "src/app/shared/Note.svelte"
import FeedControls from "src/app/shared/FeedControls.svelte" import FeedControls from "src/app/shared/FeedControls.svelte"
import {createFeed} from "src/app/util" import {createFeed, router} from "src/app/util"
import type {Feed} from "src/domain" import type {Feed} from "src/domain"
import {env} from "src/engine"
export let feed: Feed export let feed: Feed
export let anchor = null export let anchor = null
@ -24,6 +26,8 @@
export let showGroup = false export let showGroup = false
export let onEvent = null export let onEvent = null
const splits = [["zap", $env.PLATFORM_PUBKEY, "", "1"]]
const shouldHideReplies = showControls ? synced("Feed.shouldHideReplies", false) : writable(false) const shouldHideReplies = showControls ? synced("Feed.shouldHideReplies", false) : writable(false)
const reload = async () => { const reload = async () => {
@ -99,6 +103,14 @@
{anchor} {anchor}
{note} /> {note} />
</div> </div>
{#if i > 20 && parseInt(hash(note.id)) % 100 === 0}
<Card class="flex items-center justify-between">
<p class="text-xl">Enjoying Coracle?</p>
<Anchor modal button accent href={router.at("zap").qp({splits}).toString()}>
Zap the developer
</Anchor>
</Card>
{/if}
{/each} {/each}
</FlexColumn> </FlexColumn>

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}
@ -89,8 +95,6 @@
let search = getSearch(feed.definition) let search = getSearch(feed.definition)
$: subFeeds = getFeedArgs(feed.definition as any) $: subFeeds = getFeedArgs(feed.definition as any)
$: console.log(feed)
</script> </script>
<div class="flex flex-grow items-center justify-end gap-2"> <div class="flex flex-grow items-center justify-end gap-2">
@ -132,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,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

@ -48,6 +48,7 @@
mention, mention,
tracker, tracker,
hints, hints,
mentionEvent,
repository, repository,
unmuteNote, unmuteNote,
muteNote, muteNote,
@ -130,7 +131,7 @@
const crossPost = async (address = null) => { const crossPost = async (address = null) => {
const content = JSON.stringify(note as SignedEvent) const content = JSON.stringify(note as SignedEvent)
const tags = [...hints.tagEvent(note).unwrap(), mention(note.pubkey), ...getClientTags()] const tags = [...mentionEvent(note), mention(note.pubkey), ...getClientTags()]
let template let template
if (note.kind === 1) { if (note.kind === 1) {
@ -148,7 +149,7 @@
const startZap = () => { const startZap = () => {
const zapTags = tags.whereKey("zap") const zapTags = tags.whereKey("zap")
const defaultSplit = hints.tagPubkey(note.pubkey).setKey("zap").append("1").valueOf() const defaultSplit = ["zap", ...mention(note.pubkey).slice(1), "1"]
const splits = zapTags.exists() ? zapTags.unwrap() : [defaultSplit] const splits = zapTags.exists() ? zapTags.unwrap() : [defaultSplit]
router router

View File

@ -48,7 +48,9 @@
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 isStartOrEnd = i => Boolean(isBoundary(i - 1) || isBoundary(i + 1))
$: shortContent = showEntire $: shortContent = showEntire
? fullContent ? fullContent
@ -65,7 +67,7 @@
</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}
@ -84,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}
@ -93,8 +95,6 @@
<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}

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

@ -12,7 +12,14 @@
import RelayCardActions from "src/app/shared/RelayCardActions.svelte" import RelayCardActions from "src/app/shared/RelayCardActions.svelte"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
import {displayRelayUrl, RelayMode} from "src/domain" import {displayRelayUrl, RelayMode} from "src/domain"
import {deriveRelay, canSign, getSetting, setRelayPolicy, deriveUserRelayPolicy} from "src/engine" import {
deriveRelay,
canSign,
getSetting,
setInboxPolicy,
setOutboxPolicy,
deriveUserRelayPolicy,
} from "src/engine"
export let url export let url
export let claim = null export let claim = null
@ -27,7 +34,15 @@
const relay = deriveRelay(url) const relay = deriveRelay(url)
const policy = deriveUserRelayPolicy(url) const policy = deriveUserRelayPolicy(url)
const policySetter = mode => () => setRelayPolicy({...$policy, [mode]: !$policy[mode]}) const policySetter = mode => () => {
const newPolicy = {...$policy, [mode]: !$policy[mode]}
if (mode === RelayMode.Inbox) {
setInboxPolicy(newPolicy)
} else {
setOutboxPolicy(newPolicy)
}
}
</script> </script>
<div <div
@ -95,18 +110,47 @@
{#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" />
<div> <div>
<Chip <Popover triggerType="mouseenter" class="inline-block">
pad <div slot="trigger">
class={cx("cursor-pointer transition-opacity", {"opacity-50": !$policy.read})} <Chip
on:click={policySetter(RelayMode.Read)}> pad
<i class="fa fa-book-open text-neutral-300" /> Read class={cx("cursor-pointer transition-opacity", {"opacity-50": !$policy.read})}
</Chip> on:click={policySetter(RelayMode.Read)}>
<Chip <i class="fa fa-book-open text-neutral-300" /> Read
pad </Chip>
class={cx("cursor-pointer transition-opacity", {"opacity-50": !$policy.write})} </div>
on:click={policySetter(RelayMode.Write)}> <div slot="tooltip">
<i class="fa fa-feather text-neutral-300" /> Write Notes intended for you will {$policy.read ? "" : "not"} be delivered to this relay.
</Chip> </div>
</Popover>
<Popover triggerType="mouseenter" class="inline-block">
<div slot="trigger">
<Chip
pad
class={cx("cursor-pointer transition-opacity", {"opacity-50": !$policy.write})}
on:click={policySetter(RelayMode.Write)}>
<i class="fa fa-feather text-neutral-300" /> Write
</Chip>
</div>
<div slot="tooltip">
Notes you publish will {$policy.write ? "" : "not"} be sent to this relay.
</div>
</Popover>
{#if $canSign}
<Popover triggerType="mouseenter" class="inline-block">
<div slot="trigger">
<Chip
pad
class={cx("cursor-pointer transition-opacity", {"opacity-50": !$policy.inbox})}
on:click={policySetter(RelayMode.Inbox)}>
<i class="fa fa-inbox text-neutral-300" /> Inbox
</Chip>
</div>
<div slot="tooltip">
Encrypted messages will {$policy.inbox ? "" : "not"} be delivered to this relay.
</div>
</Popover>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -5,9 +5,7 @@
import {DIRECT_MESSAGE} from "@welshman/util" import {DIRECT_MESSAGE} from "@welshman/util"
import {formatTimestamp} from "src/util/misc" import {formatTimestamp} from "src/util/misc"
import Channel from "src/partials/Channel.svelte" import Channel from "src/partials/Channel.svelte"
import Content from "src/partials/Content.svelte"
import Popover from "src/partials/Popover.svelte" import Popover from "src/partials/Popover.svelte"
import Modal from "src/partials/Modal.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import PersonCircles from "src/app/shared/PersonCircles.svelte" import PersonCircles from "src/app/shared/PersonCircles.svelte"
import PersonAbout from "src/app/shared/PersonAbout.svelte" import PersonAbout from "src/app/shared/PersonAbout.svelte"
@ -23,7 +21,6 @@
markChannelRead, markChannelRead,
getChannelIdFromEvent, getChannelIdFromEvent,
listenForMessages, listenForMessages,
sortEventsDesc,
ensureMessagePlaintext, ensureMessagePlaintext,
} from "src/engine" } from "src/engine"
@ -46,35 +43,9 @@
return sendLegacyMessage(channelId, content) return sendLegacyMessage(channelId, content)
} }
const [message] = sortEventsDesc($messages || []) sendMessage(channelId, content)
if (!message || message?.kind === 4) {
confirmMessage = content
} else {
sendMessage(channelId, content)
}
} }
const confirmNip04 = () => {
sendLegacyMessage(channelId, confirmMessage)
confirmMessage = null
}
const confirmNip44 = () => {
sendMessage(channelId, confirmMessage)
confirmMessage = null
}
const abortMessage = () => {
if (confirmMessage) {
ctrl.setMessage(confirmMessage)
}
confirmMessage = null
}
let confirmMessage, ctrl
onMount(() => { onMount(() => {
markChannelRead(channelId) markChannelRead(channelId)
@ -88,7 +59,7 @@
document.title = `Direct Messages` document.title = `Direct Messages`
</script> </script>
<Channel {channelId} {pubkeys} bind:this={ctrl} messages={$messages} sendMessage={send} {initialMessage}> <Channel {pubkeys} messages={$messages} sendMessage={send} {initialMessage}>
<div slot="header" class="flex h-16 items-start gap-4 overflow-hidden p-1 px-4"> <div slot="header" class="flex h-16 items-start gap-4 overflow-hidden p-1 px-4">
<div class="flex items-center gap-4 pt-1"> <div class="flex items-center gap-4 pt-1">
<Anchor class="fa fa-arrow-left cursor-pointer text-2xl" href="/channels" /> <Anchor class="fa fa-arrow-left cursor-pointer text-2xl" href="/channels" />
@ -134,20 +105,22 @@
class:text-neutral-100={message.pubkey !== $session.pubkey}> class:text-neutral-100={message.pubkey !== $session.pubkey}>
{formatTimestamp(message.created_at)} {formatTimestamp(message.created_at)}
{#if message.kind === 4} {#if message.kind === 4}
<Popover> <Popover triggerType="mouseenter">
<i slot="trigger" class="fa fa-unlock cursor-pointer text-neutral-200" /> <i slot="trigger" class="fa fa-unlock cursor-pointer text-neutral-400" />
<p slot="tooltip"> <p slot="tooltip">
This message was sent using nostr's legacy DMs, which have a number of shortcomings. This message was sent using nostr's legacy DMs, which have a number of shortcomings.
Read more <Anchor underline modal href="/help/nip-44-dms">here</Anchor>. Read more <Anchor underline modal href="/help/nip-44-dms">here</Anchor>.
</p> </p>
</Popover> </Popover>
{:else} {:else}
<Popover> <Popover triggerType="mouseenter">
<i slot="trigger" class="fa fa-lock cursor-pointer text-neutral-200" /> <i slot="trigger" class="fa fa-lock cursor-pointer text-neutral-400" />
<div slot="tooltip" class="flex flex-col gap-2"> <div slot="tooltip" class="flex flex-col gap-2">
<p> <p>
This message was sent using nostr's new group chat specification, which solves several This message was sent using nostr's new group chat specification, which solves several
problems with legacy DMs. Read more <Anchor underline modal href="/help/nip-44-dms">here</Anchor>. problems with legacy DMs. Read more <Anchor underline modal href="/help/nip-44-dms"
>here</Anchor
>.
</p> </p>
<p> <p>
Note that these messages are not yet universally supported. Make sure the person Note that these messages are not yet universally supported. Make sure the person
@ -159,29 +132,3 @@
</small> </small>
</div> </div>
</Channel> </Channel>
{#if confirmMessage}
<Modal onEscape={abortMessage}>
<Content size="lg">
<p class="flex items-center gap-4 text-xl">
<i class="fa fa-info-circle" /> Auto-upgrade notice
</p>
<p>
This conversation has not yet been upgraded to use <Anchor
underline
modal
href="/help/nip-44-dms">new-style DMs</Anchor
>.
</p>
<p>
You should make sure @{displayProfileByPubkey(pubkeys[0])} is using a compatible nostr client,
or you can choose to send an old-style message instead.
</p>
<p>How would you like to send this message?</p>
<div class="flex flex-col gap-2 py-4 sm:flex-row">
<Anchor button on:click={confirmNip04}>Send using Legacy DMs</Anchor>
<Anchor button accent on:click={confirmNip44}>Send using NIP 44</Anchor>
</div>
</Content>
</Modal>
{/if}

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

@ -3,7 +3,7 @@
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 {displayGroupMeta} from "src/domain" import {displayGroupMeta} from "src/domain"
import {deriveGroupMeta, createAndPublish, hints, deriveAdminKeyForGroup} from "src/engine" import {deriveGroupMeta, deleteGroupMeta, deriveAdminKeyForGroup} from "src/engine"
import {router} from "src/app/util/router" import {router} from "src/app/util/router"
export let address export let address
@ -14,14 +14,7 @@
const abort = () => router.pop() const abort = () => router.pop()
const confirm = () => { const confirm = () => {
createAndPublish({ deleteGroupMeta(address)
kind: 5,
tags: [["a", address]],
relays: hints.WithinContext(address).getUrls(),
sk: $adminKey.privkey,
forcePlatform: false,
})
showInfo("Group deleted!") showInfo("Group deleted!")
router.pop() router.pop()
} }

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

View File

@ -19,7 +19,7 @@
createAndPublish, createAndPublish,
updateSingleton, updateSingleton,
publishProfile, publishProfile,
setRelayPolicies, setOutboxPolicies,
tagsFromContent, tagsFromContent,
requestRelayAccess, requestRelayAccess,
loginWithPrivateKey, loginWithPrivateKey,
@ -76,7 +76,7 @@
} }
// Do this first so we know where to publish everything else // Do this first so we know where to publish everything else
setRelayPolicies(() => relays) setOutboxPolicies(() => relays)
// Re-save preferences now that we have a key and relays // Re-save preferences now that we have a key and relays
publishProfile(profile) publishProfile(profile)

View File

@ -17,13 +17,13 @@
export let eid = null export let eid = null
export let anonymous = false export let anonymous = false
export let callback = null export let callback = null
export let amount = getSetting("default_zap")
let zaps = [] let zaps = []
let message = "" let message = ""
let loading = false let loading = false
let totalAmount = getSetting("default_zap")
const updateZaps = (message, totalAmount) => { const updateZaps = (message, amount) => {
let totalWeight = 0 let totalWeight = 0
zaps = doPipe(splits, [ zaps = doPipe(splits, [
@ -39,7 +39,7 @@
map(([pubkey, relay, weight]: string[]) => ({ map(([pubkey, relay, weight]: string[]) => ({
relay, relay,
pubkey, pubkey,
amount: Math.round(totalAmount * (parseFloat(weight) / totalWeight)), amount: Math.round(amount * (parseFloat(weight) / totalWeight)),
status: "pending", status: "pending",
})), })),
sortBy((split: any) => -split.amount), sortBy((split: any) => -split.amount),
@ -137,7 +137,7 @@
} }
// Watch inputs and update zaps // Watch inputs and update zaps
$: updateZaps(message, totalAmount) $: updateZaps(message, amount)
// Initialize bitcoin connect // Initialize bitcoin connect
init({appName: import.meta.env.VITE_APP_NAME}) init({appName: import.meta.env.VITE_APP_NAME})
@ -146,7 +146,7 @@
{#if zaps.length > 0} {#if zaps.length > 0}
<h1 class="staatliches text-2xl">Send a zap</h1> <h1 class="staatliches text-2xl">Send a zap</h1>
<Textarea bind:value={message} placeholder="Send a message with your zap (optional)" /> <Textarea bind:value={message} placeholder="Send a message with your zap (optional)" />
<Input bind:value={totalAmount}> <Input bind:value={amount}>
<i slot="before" class="fa fa-bolt" /> <i slot="before" class="fa fa-bolt" />
<span slot="after" class="-mt-1">sats</span> <span slot="after" class="-mt-1">sats</span>
</Input> </Input>

View File

@ -1,6 +1,5 @@
import {last} from "@welshman/lib" import {last} from "@welshman/lib"
import {LOCAL_RELAY_URL, normalizeRelayUrl as _normalizeRelayUrl} from "@welshman/util" import {LOCAL_RELAY_URL, normalizeRelayUrl as _normalizeRelayUrl} from "@welshman/util"
import {getRelayTags} from "src/util/nostr"
// Utils related to bare urls // Utils related to bare urls
@ -42,25 +41,23 @@ export const filterRelaysByNip = (nip: number, relays) =>
export enum RelayMode { export enum RelayMode {
Read = "read", Read = "read",
Write = "write", Write = "write",
Inbox = "inbox",
} }
export type RelayPolicy = { export type RelayPolicy = {
url: string url: string
read: boolean read: boolean
write: boolean write: boolean
inbox: boolean
} }
export const makeRelayPolicy = ( export const makeRelayPolicy = ({
relayPolicy: Partial<RelayPolicy> & {url: string}, url,
): RelayPolicy => ({ ...relayPolicy
}: Partial<RelayPolicy> & {url: string}): RelayPolicy => ({
url: normalizeRelayUrl(url),
read: false, read: false,
write: false, write: false,
inbox: false,
...relayPolicy, ...relayPolicy,
}) })
export const makeRelayPoliciesFromTags = (tags: string[][]) =>
getRelayTags(tags).map(([_, url, mode]) => ({
url: normalizeRelayUrl(url),
write: !mode || mode === RelayMode.Write,
read: !mode || mode === RelayMode.Read,
}))

View File

@ -10,11 +10,13 @@ import {
Address, Address,
isSignedEvent, isSignedEvent,
normalizeRelayUrl, normalizeRelayUrl,
FEEDS,
FOLLOWS, FOLLOWS,
RELAYS, RELAYS,
PROFILE, PROFILE,
MUTES, MUTES,
WRAP_NIP04, WRAP_NIP04,
INBOX_RELAYS,
} from "@welshman/util" } from "@welshman/util"
import {Fetch, chunk, createMapOf, randomId, seconds, sleep, tryFunc} from "hurdak" import {Fetch, chunk, createMapOf, randomId, seconds, sleep, tryFunc} from "hurdak"
import { import {
@ -39,6 +41,7 @@ import {
editSingleton, editSingleton,
createSingleton, createSingleton,
readSingleton, readSingleton,
makeRelayPolicy,
} from "src/domain" } from "src/domain"
import type {RelayPolicy} from "src/domain" import type {RelayPolicy} from "src/domain"
import type {Session, NostrConnectHandler} from "src/engine/model" import type {Session, NostrConnectHandler} from "src/engine/model"
@ -80,6 +83,8 @@ import {
zappers, zappers,
getPlaintext, getPlaintext,
anonymous, anonymous,
mentionGroup,
userRelayPolicies,
} from "src/engine/state" } from "src/engine/state"
import {loadHandle, loadZapper} from "src/engine/requests" import {loadHandle, loadZapper} from "src/engine/requests"
@ -311,9 +316,9 @@ export const wrapWithFallback = async (template, {author = null, wrap}) => {
return events return events
} }
const addATags = (template, addresses) => ({ const addGroupATags = (template, addresses) => ({
...template, ...template,
tags: [...template.tags, ...addresses.map(a => ["a", a])], tags: [...template.tags, ...addresses.map(mentionGroup)],
}) })
// Utils for publishing group-related messages // Utils for publishing group-related messages
@ -376,7 +381,7 @@ export const publishToGroupsPublicly = async (addresses, template, {anonymous =
} }
} }
const event = await sign(addATags(template, addresses), {anonymous}) const event = await sign(addGroupATags(template, addresses), {anonymous})
const relays = hints.PublishEvent(event).getUrls() const relays = hints.PublishEvent(event).getUrls()
return publish({event, relays, forcePlatform: false}) return publish({event, relays, forcePlatform: false})
@ -387,7 +392,7 @@ export const publishToGroupsPrivately = async (addresses, template, {anonymous =
const pubs = [] const pubs = []
for (const address of addresses) { for (const address of addresses) {
const relays = hints.WithinContext(address).getUrls() const relays = hints.WithinContext(address).getUrls()
const thisTemplate = addATags(template, [address]) const thisTemplate = addGroupATags(template, [address])
const sharedKey = deriveSharedKeyForGroup(address).get() const sharedKey = deriveSharedKeyForGroup(address).get()
if (!address.startsWith("35834:")) { if (!address.startsWith("35834:")) {
@ -489,7 +494,7 @@ export const publishAdminKeyShares = async (address, pubkeys) => {
const {privkey} = deriveAdminKeyForGroup(address).get() const {privkey} = deriveAdminKeyForGroup(address).get()
const template = createEvent(24, { const template = createEvent(24, {
tags: [ tags: [
["a", address], mentionGroup(address),
["role", "admin"], ["role", "admin"],
["privkey", privkey], ["privkey", privkey],
...getClientTags(), ...getClientTags(),
@ -506,7 +511,7 @@ export const publishGroupInvites = async (address, pubkeys, gracePeriod = 0) =>
const {privkey} = deriveSharedKeyForGroup(address).get() const {privkey} = deriveSharedKeyForGroup(address).get()
const template = createEvent(24, { const template = createEvent(24, {
tags: [ tags: [
["a", address], mentionGroup(address),
["role", "member"], ["role", "member"],
["privkey", privkey], ["privkey", privkey],
["grace_period", String(gracePeriod)], ["grace_period", String(gracePeriod)],
@ -523,13 +528,13 @@ export const publishGroupEvictions = async (address, pubkeys) =>
address, address,
pubkeys, pubkeys,
createEvent(24, { createEvent(24, {
tags: [["a", address], ...getClientTags()], tags: [mentionGroup(address), ...getClientTags()],
}), }),
) )
export const publishGroupMembers = async (address, op, pubkeys) => { export const publishGroupMembers = async (address, op, pubkeys) => {
const template = createEvent(27, { const template = createEvent(27, {
tags: [["op", op], ["a", address], ...getClientTags(), ...pubkeys.map(mention)], tags: [["op", op], mentionGroup(address), ...getClientTags(), ...pubkeys.map(mention)],
}) })
return publishAsGroupAdminPrivately(address, template) return publishAsGroupAdminPrivately(address, template)
@ -576,7 +581,7 @@ export const publishGroupMeta = (address, identifier, meta, listPublicly) => {
} }
export const deleteGroupMeta = address => export const deleteGroupMeta = address =>
publishAsGroupAdminPublicly(address, createEvent(5, {tags: [["a", address]]})) publishAsGroupAdminPublicly(address, createEvent(5, {tags: [mentionGroup(address)]}))
// Member functions // Member functions
@ -599,7 +604,7 @@ export const publishGroupEntryRequest = (address, claim = null) => {
} else { } else {
setGroupStatus(pubkey.get(), address, now(), {access: GroupAccess.Requested}) setGroupStatus(pubkey.get(), address, now(), {access: GroupAccess.Requested})
const tags = [...getClientTags(), ["a", address]] const tags = [...getClientTags(), mentionGroup(address)]
if (claim) { if (claim) {
tags.push(["claim", claim]) tags.push(["claim", claim])
@ -623,7 +628,7 @@ export const publishGroupExitRequest = address => {
address, address,
createEvent(26, { createEvent(26, {
content: `${displayProfileByPubkey(pubkey.get())} is leaving the group`, content: `${displayProfileByPubkey(pubkey.get())} is leaving the group`,
tags: [...getClientTags(), ["a", address]], tags: [...getClientTags(), mentionGroup(address)],
}), }),
) )
} }
@ -632,7 +637,7 @@ export const publishGroupExitRequest = address => {
export const publishCommunitiesList = addresses => export const publishCommunitiesList = addresses =>
createAndPublish({ createAndPublish({
kind: 10004, kind: 10004,
tags: [...addresses.map(a => ["a", a]), ...getClientTags()], tags: [...addresses.map(mentionGroup), ...getClientTags()],
relays: hints.WriteRelays().getUrls(), relays: hints.WriteRelays().getUrls(),
}) })
@ -685,7 +690,9 @@ export const updateSingleton = async (kind: number, modifyTags: ModifyTags) => {
// If we don't have a recent version loaded, re-fetch to avoid dropping updates // If we don't have a recent version loaded, re-fetch to avoid dropping updates
if ((event?.created_at || 0) < now() - seconds(5, "minute")) { if ((event?.created_at || 0) < now() - seconds(5, "minute")) {
console.log("loading")
const loadedEvent = await loadOne({relays: hints.User().getUrls(), filters}) const loadedEvent = await loadOne({relays: hints.User().getUrls(), filters})
console.log("loaded", loadedEvent)
if ((loadedEvent?.created_at || 0) > (event?.created_at || 0)) { if ((loadedEvent?.created_at || 0) > (event?.created_at || 0)) {
event = loadedEvent event = loadedEvent
@ -710,8 +717,12 @@ export const updateSingleton = async (kind: number, modifyTags: ModifyTags) => {
encryptable = createSingleton({...singleton, publicTags}) encryptable = createSingleton({...singleton, publicTags})
} }
console.log(1)
const template = await encryptable.reconcile(encrypt) const template = await encryptable.reconcile(encrypt)
console.log(2, template)
await createAndPublish({...template, content, relays}) await createAndPublish({...template, content, relays})
} }
@ -743,6 +754,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) =>
@ -754,7 +771,7 @@ export const requestRelayAccess = async (url: string, claim: string, sk?: string
sk, sk,
}) })
export const setRelayPolicies = async (modifyTags: ModifyTags) => { export const setOutboxPolicies = async (modifyTags: ModifyTags) => {
if (canSign.get()) { if (canSign.get()) {
updateSingleton(RELAYS, modifyTags) updateSingleton(RELAYS, modifyTags)
} else { } else {
@ -762,8 +779,26 @@ export const setRelayPolicies = async (modifyTags: ModifyTags) => {
} }
} }
export const setRelayPolicy = ({url, read, write}: RelayPolicy) => export const setInboxPolicies = async (modifyTags: ModifyTags) =>
setRelayPolicies($tags => { updateSingleton(INBOX_RELAYS, modifyTags)
export const setInboxPolicy = ({url, inbox}: RelayPolicy) => {
// Only update inbox policies if they already exist or we're adding them
if (inbox || get(userRelayPolicies).find(p => p.url === url && p.inbox)) {
setInboxPolicies($tags => {
$tags = $tags.filter(t => t[1] !== url)
if (inbox) {
$tags.push(["relay", url])
}
return $tags
})
}
}
export const setOutboxPolicy = ({url, read, write}: RelayPolicy) =>
setOutboxPolicies($tags => {
$tags = $tags.filter(t => t[1] !== url) $tags = $tags.filter(t => t[1] !== url)
if (read && write) { if (read && write) {
@ -778,7 +813,10 @@ export const setRelayPolicy = ({url, read, write}: RelayPolicy) =>
}) })
export const leaveRelay = async (url: string) => { export const leaveRelay = async (url: string) => {
await setRelayPolicy({url, read: false, write: false}) await Promise.all([
setInboxPolicy(makeRelayPolicy({url})),
setOutboxPolicy(makeRelayPolicy({url})),
])
// Make sure the new relay selections get to the old relay // Make sure the new relay selections get to the old relay
if (pubkey.get()) { if (pubkey.get()) {
@ -793,7 +831,7 @@ export const joinRelay = async (url: string, claim?: string) => {
await requestRelayAccess(url, claim) await requestRelayAccess(url, claim)
} }
await setRelayPolicy({url, read: true, write: true}) await setOutboxPolicy(makeRelayPolicy({url, read: true, write: true}))
// Re-publish user meta to the new relay // Re-publish user meta to the new relay
if (pubkey.get()) { if (pubkey.get()) {
@ -889,7 +927,7 @@ export const sendMessage = async (channelId: string, content: string) => {
publish({ publish({
event: rumor.wrap, event: rumor.wrap,
relays: hints.merge(recipients.map(hints.PublishMessage)).getUrls(), relays: hints.PublishMessage(recipient).getUrls(),
forcePlatform: false, forcePlatform: false,
}) })
} }

View File

@ -4,10 +4,12 @@ import type {TrustedEvent} from "@welshman/util"
import { import {
RELAYS, RELAYS,
PROFILE, PROFILE,
INBOX_RELAYS,
HANDLER_INFORMATION, HANDLER_INFORMATION,
NAMED_BOOKMARKS, NAMED_BOOKMARKS,
COMMUNITIES, COMMUNITIES,
FEED, FEED,
FEEDS,
MUTES, MUTES,
FOLLOWS, FOLLOWS,
APP_DATA, APP_DATA,
@ -50,12 +52,12 @@ const getFiltersForKey = (key: string, authors: string[]) => {
case "pubkey/feeds": case "pubkey/feeds":
return [{authors, kinds: [NAMED_BOOKMARKS, FEED]}] return [{authors, kinds: [NAMED_BOOKMARKS, FEED]}]
case "pubkey/relays": case "pubkey/relays":
return [{authors, kinds: [RELAYS]}] return [{authors, kinds: [RELAYS, INBOX_RELAYS]}]
case "pubkey/profile": case "pubkey/profile":
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

@ -43,8 +43,10 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import { import {
WRAP, WRAP,
FEEDS,
COMMUNITY, COMMUNITY,
GROUP, GROUP,
INBOX_RELAYS,
WRAP_NIP04, WRAP_NIP04,
COMMUNITIES, COMMUNITIES,
READ_RECEIPT, READ_RECEIPT,
@ -72,6 +74,7 @@ import {
LOCAL_RELAY_URL, LOCAL_RELAY_URL,
getFilterResultCardinality, getFilterResultCardinality,
isShareableRelayUrl, isShareableRelayUrl,
isReplaceable,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, RouterScenario, TrustedEvent, SignedEvent} from "@welshman/util" import type {Filter, RouterScenario, TrustedEvent, SignedEvent} from "@welshman/util"
import { import {
@ -97,6 +100,8 @@ import {
repostKinds, repostKinds,
noteKinds, noteKinds,
reactionKinds, reactionKinds,
getRelayTags,
getRelayTagValues,
} from "src/util/nostr" } from "src/util/nostr"
import logger from "src/util/logger" import logger from "src/util/logger"
import type { import type {
@ -110,10 +115,10 @@ import type {
Handle, Handle,
} from "src/domain" } from "src/domain"
import { import {
RelayMode,
EDITABLE_LIST_KINDS, EDITABLE_LIST_KINDS,
getSingletonValues, getSingletonValues,
makeSingleton, makeSingleton,
makeRelayPoliciesFromTags,
ListSearch, ListSearch,
FeedSearch, FeedSearch,
profileHasName, profileHasName,
@ -963,8 +968,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)) ||
@ -972,7 +976,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)
) )
} }
@ -1105,6 +1109,19 @@ export const relayLists = withGetter(
}), }),
) )
export const inboxRelayLists = withGetter(
deriveEventsMapped<PublishedSingleton>({
filters: [{kinds: [INBOX_RELAYS]}],
itemToEvent: prop("event"),
eventToItem: event =>
readSingleton(
asDecryptedEvent(event, {
content: getPlaintext(event),
}),
),
}),
)
export const legacyRelayLists = withGetter( export const legacyRelayLists = withGetter(
deriveEventsMapped<{event: TrustedEvent; policy: RelayPolicy[]}>({ deriveEventsMapped<{event: TrustedEvent; policy: RelayPolicy[]}>({
filters: [{kinds: [FOLLOWS]}], filters: [{kinds: [FOLLOWS]}],
@ -1115,11 +1132,7 @@ export const legacyRelayLists = withGetter(
JSON.parse(event.content) as Record<string, {write: boolean; read: boolean}>, JSON.parse(event.content) as Record<string, {write: boolean; read: boolean}>,
) )
.filter(([url]) => isShareableRelayUrl(url)) .filter(([url]) => isShareableRelayUrl(url))
.map(([url, {write = true, read = true}]) => ({ .map(([url, {write = true, read = true}]) => makeRelayPolicy({url, read, write}))
url: normalizeRelayUrl(url),
write,
read,
}))
return {event, policy} return {event, policy}
} catch (e) { } catch (e) {
@ -1130,21 +1143,54 @@ export const legacyRelayLists = withGetter(
) )
export const relayPoliciesByPubkey = withGetter( export const relayPoliciesByPubkey = withGetter(
derived([relayLists, legacyRelayLists], ([$relayLists, $legacyRelayLists]) => { derived(
const policies = new Map<string, RelayPolicy[]>() [relayLists, inboxRelayLists, legacyRelayLists],
([$relayLists, $inboxRelayLists, $legacyRelayLists]) => {
const policiesByUrlByPubkey = new Map<string, Map<string, RelayPolicy>>()
for (const {event, publicTags} of $relayLists) { for (const {event, publicTags} of $relayLists) {
policies.set(event.pubkey, makeRelayPoliciesFromTags(publicTags)) const policiesByUrl = new Map()
}
for (const {event, policy} of $legacyRelayLists) { for (const [_, url, mode] of getRelayTags(publicTags)) {
if (!policies.has(event.pubkey)) { const read = !mode || mode === RelayMode.Read
policies.set(event.pubkey, policy) const write = !mode || mode === RelayMode.Write
const policy = makeRelayPolicy({url, read, write})
policiesByUrl.set(policy.url, policy)
}
policiesByUrlByPubkey.set(event.pubkey, policiesByUrl)
} }
}
return policies for (const {event, publicTags} of $inboxRelayLists) {
}), const policiesByUrl = policiesByUrlByPubkey.get(event.pubkey) || new Map()
for (const url of getRelayTagValues(publicTags)) {
const normalizedUrl = normalizeRelayUrl(url)
const defaultPolicy = makeRelayPolicy({url})
const policy = policiesByUrl.get(defaultPolicy.url)
policiesByUrl.set(normalizedUrl, {...defaultPolicy, ...policy, inbox: true})
}
policiesByUrlByPubkey.set(event.pubkey, policiesByUrl)
}
for (const {event, policy} of $legacyRelayLists) {
if (!policiesByUrlByPubkey.has(event.pubkey)) {
policiesByUrlByPubkey.set(event.pubkey, indexBy(prop("url"), policy))
}
}
const result = new Map<string, RelayPolicy[]>()
for (const [pubkey, policiesByUrl] of policiesByUrlByPubkey.entries()) {
result.set(pubkey, Array.from(policiesByUrl.values()))
}
return result
},
),
) )
export const getPubkeyRelayPolicies = (pubkey: string, mode: string = null) => { export const getPubkeyRelayPolicies = (pubkey: string, mode: string = null) => {
@ -1155,7 +1201,14 @@ export const getPubkeyRelayPolicies = (pubkey: string, mode: string = null) => {
export const userRelayPolicies = derived( export const userRelayPolicies = derived(
[relayPoliciesByPubkey, pubkey, anonymous], [relayPoliciesByPubkey, pubkey, anonymous],
([$m, $pk, $anon]) => $m.get($pk) || makeRelayPoliciesFromTags($anon.relays), ([$m, $pk, $anon]) =>
$m.get($pk) ||
getRelayTags($anon.relays).map(([_, url, mode]) => {
const read = !mode || mode === RelayMode.Read
const write = !mode || mode === RelayMode.Write
return makeRelayPolicy({url, read, write})
}),
) )
export const deriveUserRelayPolicy = url => export const deriveUserRelayPolicy = url =>
@ -1278,15 +1331,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))
@ -1516,6 +1590,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
@ -1560,6 +1635,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)
} }
@ -1598,7 +1677,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 => {
@ -1710,8 +1796,28 @@ Object.assign(NetworkContext, {
export const uniqTags = tags => export const uniqTags = tags =>
uniqBy((t: string[]) => (t[0] === "param" ? t.join(":") : t.slice(0, 2).join(":")), tags) uniqBy((t: string[]) => (t[0] === "param" ? t.join(":") : t.slice(0, 2).join(":")), tags)
export const mention = (pubkey: string, ...args: unknown[]) => export const mention = (pubkey: string, ...args: unknown[]) => [
hints.tagPubkey(pubkey).append(displayProfileByPubkey(pubkey)).valueOf() "p",
pubkey,
hints.FromPubkeys([pubkey]).getUrl(),
displayProfileByPubkey(pubkey),
]
export const mentionGroup = (address: string, ...args: unknown[]) => [
"a",
hints.WithinContext(address).getUrl(),
]
export const mentionEvent = (event: TrustedEvent, mark = "") => {
const url = hints.Event(event).getUrl()
const tags = [["e", event.id, url, mark, event.pubkey]]
if (isReplaceable(event)) {
tags.push(["a", getAddress(event), url, mark, event.pubkey])
}
return tags
}
export const tagsFromContent = (content: string) => { export const tagsFromContent = (content: string) => {
const tags = [] const tags = []
@ -1786,7 +1892,7 @@ export const getReplyTags = (parent: TrustedEvent) => {
// Add a/e-tags for the parent event // Add a/e-tags for the parent event
const mark = replies.exists() ? "reply" : "root" const mark = replies.exists() ? "reply" : "root"
for (const t of hints.tagEvent(parent, mark).valueOf()) { for (const t of mentionEvent(parent, mark)) {
replyTags.push(t.valueOf()) replyTags.push(t.valueOf())
} }
@ -1802,7 +1908,7 @@ export const getReactionTags = (parent: TrustedEvent) => {
} }
// Add a/e-tags for the parent event // Add a/e-tags for the parent event
for (const t of hints.tagEvent(parent, "root").valueOf()) { for (const t of mentionEvent(parent, "root")) {
replyTags.push(t.valueOf()) replyTags.push(t.valueOf())
} }
@ -2007,19 +2113,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) 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.log('deleting', name, removedRecords.length, current.length) 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) 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,28 +1,26 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {sleep} from "hurdak" import {sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {INBOX_RELAYS} from "@welshman/util"
import {prop, max, reverse, pluck, sortBy, last} from "ramda" import {prop, max, reverse, pluck, sortBy, last} from "ramda"
import {fly} from "src/util/transition" import {fly} from "src/util/transition"
import {createScroller, synced} from "src/util/misc" import {createScroller} from "src/util/misc"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
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 Toggle from "src/partials/Toggle.svelte" import Toggle from "src/partials/Toggle.svelte"
import FlexColumn from "src/partials/FlexColumn.svelte" import FlexColumn from "src/partials/FlexColumn.svelte"
import ImageInput from "src/partials/ImageInput.svelte" import ImageInput from "src/partials/ImageInput.svelte"
import {nip44} from "src/engine" import {nip44, repository} from "src/engine"
export let pubkeys export let pubkeys
export let channelId
export let messages: TrustedEvent[]
export let sendMessage export let sendMessage
export let initialMessage = "" export let initialMessage = ""
export let messages: TrustedEvent[]
const loading = sleep(30_000) const loading = sleep(30_000)
const useNip44 = synced(`useNip44/${channelId}`, true)
const startScroller = () => { const startScroller = () => {
scroller?.stop() scroller?.stop()
scroller = createScroller(loadMore, {element, reverse: true}) scroller = createScroller(loadMore, {element, reverse: true})
@ -36,6 +34,10 @@
let limit = 10 let limit = 10
let showNewMessages = false let showNewMessages = false
let groupedMessages = [] let groupedMessages = []
let useNip44 =
pubkeys.length > 2 ||
($nip44.isEnabled() &&
repository.query([{kinds: [INBOX_RELAYS], authors: pubkeys}]).length === pubkeys.length)
onMount(() => { onMount(() => {
startScroller() startScroller()
@ -76,7 +78,7 @@
if (content) { if (content) {
textarea.value = "" textarea.value = ""
await sendMessage(content, $useNip44) await sendMessage(content, useNip44)
stickToBottom() stickToBottom()
} }
@ -159,7 +161,7 @@
</div> </div>
{#if $nip44.isEnabled()} {#if $nip44.isEnabled()}
<div class="fixed bottom-0 right-12 flex items-center justify-end gap-2 p-2"> <div class="fixed bottom-0 right-12 flex items-center justify-end gap-2 p-2">
<Toggle scale={0.7} bind:value={$useNip44} /> <Toggle scale={0.7} bind:value={useNip44} />
<small> <small>
Send messages using Send messages using
<Popover class="inline"> <Popover class="inline">