Build encrypted group management

This commit is contained in:
Jonathan Staab 2023-10-18 11:17:36 -07:00 committed by Jon Staab
parent c833caf77a
commit 6bae64459d
81 changed files with 2249 additions and 429 deletions

View File

@ -3,6 +3,7 @@
# 0.3.13
- [x] Update lists to use new 30003 user bookmarks kind
- [x] Add anonymous posting
# 0.3.12

View File

@ -18,7 +18,7 @@
"devDependencies": {
"@capacitor/cli": "^4.7.3",
"@sveltejs/vite-plugin-svelte": "^1.1.0",
"@types/ramda": "^0.29.3",
"@types/ramda": "^0.29.8",
"@types/throttle-debounce": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
@ -42,6 +42,8 @@
"@capacitor/ios": "^4.7.3",
"@fortawesome/fontawesome-free": "^6.2.1",
"@noble/ciphers": "^0.2.0",
"@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@nostr-dev-kit/ndk": "^0.7.0",
"@scure/base": "^1.1.1",
"@tsconfig/svelte": "^3.0.0",
@ -61,7 +63,7 @@
"paravel": "^0.4.7",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.1",
"ramda": "^0.28.0",
"ramda": "^0.29.1",
"svelte": "^3.55.1",
"svelte-check": "^3.0.3",
"svelte-link-preview": "^0.3.3",

View File

@ -29,6 +29,12 @@
import DataExport from "src/app/views/DataExport.svelte"
import DataImport from "src/app/views/DataImport.svelte"
import Explore from "src/app/views/Explore.svelte"
import GroupList from "src/app/views/GroupList.svelte"
import GroupDetail from "src/app/views/GroupDetail.svelte"
import GroupCreate from "src/app/views/GroupCreate.svelte"
import GroupEdit from "src/app/views/GroupEdit.svelte"
import GroupInfo from "src/app/views/GroupInfo.svelte"
import GroupRotate from "src/app/views/GroupRotate.svelte"
import Help from "src/app/views/Help.svelte"
import Feeds from "src/app/views/Feeds.svelte"
import LabelCreate from "src/app/views/LabelCreate.svelte"
@ -76,6 +82,7 @@
router,
asChannelId,
asPerson,
asGroup,
asCsv,
asString,
asUrlComponent,
@ -117,6 +124,31 @@
router.register("/explore", Explore)
router.register("/groups", GroupList)
router.register("/groups/new", GroupCreate)
router.register("/groups/:address/edit", GroupEdit, {
serializers: {
address: asGroup("address"),
},
})
router.register("/groups/:address/info", GroupInfo, {
serializers: {
address: asGroup("address"),
},
})
router.register("/groups/:address/rotate", GroupRotate, {
serializers: {
address: asGroup("address"),
addMembers: asCsv("addMembers"),
removeMembers: asCsv("removeMembers"),
},
})
router.register("/groups/:address/:activeTab", GroupDetail, {
serializers: {
address: asGroup("address"),
},
})
router.register("/help/:topic", Help)
router.register("/labels/:label", LabelDetail, {
@ -166,6 +198,7 @@
requireUser: true,
serializers: {
pubkey: asPerson,
group: asGroup("group"),
},
})
router.register("/notes/:entity", NoteDetail, {

View File

@ -1,6 +1,6 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {fade} from "src/util/transition"
import {getProps} from "src/util/router"
import {canSign} from "src/engine"
import ForegroundButton from "src/partials/ForegroundButton.svelte"
import ForegroundButtons from "src/partials/ForegroundButtons.svelte"
@ -19,10 +19,18 @@
}
const createNote = () => {
const pubkeyMatch = $page.path.match(/(npub1[0-9a-z]+)/)
const pubkey = pubkeyMatch ? nip19.decode(pubkeyMatch[1]).data : null
const params = {} as any
const props = getProps($page) as any
router.at("notes/create").qp({pubkey}).open()
if ($page.path.startsWith("/people") && props.pubkey) {
params.pubkey = props.pubkey
}
if ($page.path.startsWith("/groups") && props.address) {
params.group = props.address
}
router.at("notes/create").qp(params).open()
}
$: showButtons = !$page?.path.match(/^\/conversations|channels|logout|settings/)

View File

@ -2,7 +2,7 @@
import {theme, installPrompt} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import NavItem from "src/partials/NavItem.svelte"
import {hasNewNip04Messages, hasNewNotifications, canSign} from "src/engine"
import {hasNewNip04Messages, hasNewNotifications, canSign, canUseGiftWrap} from "src/engine"
import {menuIsOpen} from "src/app/state"
const toggleTheme = () => theme.update(t => (t === "dark" ? "light" : "dark"))
@ -40,6 +40,9 @@
class="absolute left-7 top-2 h-2 w-2 rounded border border-solid border-white bg-accent" />
{/if}
</NavItem>
<NavItem disabled={!$canUseGiftWrap} href="/groups">
<i class="fa fa-circle-nodes mr-2" /> Groups
</NavItem>
<NavItem modal href="/chat/redirect">
<i class="fa fa-comment mr-2" /> Chat
</NavItem>

View File

@ -3,8 +3,8 @@ import {fromNostrURI} from "paravel"
import {nip19} from "nostr-tools"
import {Router} from "src/util/router"
import {tryJson} from "src/util/misc"
import {Naddr} from "src/util/nostr"
import {
Naddr,
decodePerson,
decodeRelay,
decodeEvent,
@ -119,6 +119,11 @@ export const asChannelId = {
decode: decodeAs("pubkeys", decodeCsv),
}
export const asGroup = k => ({
encode: a => Naddr.fromTagValue(a).encode(),
decode: decodeAs(k, naddr => Naddr.decode(naddr).asTagValue()),
})
// Router and extensions
export const router = new Router()
@ -127,6 +132,7 @@ router.extend("media", encodeURIComponent)
router.extend("labels", encodeURIComponent)
router.extend("relays", nip19.nrelayEncode)
router.extend("channels", getNip24ChannelId)
router.extend("groups", asGroup("group").encode)
router.extend("notes", (id, {relays = []} = {}) => {
if (id.includes(":")) {

View File

@ -2,7 +2,7 @@
import {nip19} from "nostr-tools"
import {debounce, throttle} from "throttle-debounce"
import {createEventDispatcher} from "svelte"
import {last, partition, propEq} from "ramda"
import {last, partition, whereEq} from "ramda"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
@ -244,8 +244,8 @@
content = content.replace(/[\u200B\u00A0]/g, " ").trim()
// Strip the @ sign in mentions
annotations.filter(propEq("prefix", "@")).forEach(({value}, index) => {
content = content.replace("@" + value, value)
annotations.filter(whereEq({prefix: "@"})).forEach(({prefix, value}, index) => {
content = content.replace(prefix + value, value)
})
return content

View File

@ -22,6 +22,7 @@
export let relays = []
export let filter: DynamicFilter = {}
export let hideControls = false
export let showGroup = false
export let noCache = false
export let onEvent = null
@ -101,6 +102,7 @@
depth={$hideReplies ? 0 : 2}
context={note.replies || []}
filters={[compileFilter(filter)]}
{showGroup}
{note} />
</div>
{/each}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {pluck, not, find, propEq, prop, equals, omit, objOf} from "ramda"
import {pluck, not, prop, equals, omit, objOf} from "ramda"
import {displayList} from "hurdak"
import {createLocalDate, fuzzy, formatTimestampAsDate} from "src/util/misc"
import {noteKinds} from "src/util/nostr"
@ -164,7 +164,7 @@
}
const getFormFilter = () => ({
kinds: filter.kinds?.map(k => find(propEq("kind", k), kinds)),
kinds: filter.kinds?.map((k: number) => kinds.find(x => x.kind === k)),
since: filter.since,
until: filter.until,
search: filter.search || "",

View File

@ -0,0 +1,40 @@
<script lang="ts">
import {ellipsize} from "hurdak"
import {parseContent} from "src/util/notes"
import Anchor from "src/partials/Anchor.svelte"
import {groups, displayGroup} from "src/engine"
export let address
export let truncate = false
const group = groups.key(address)
$: about = $group?.description || ""
$: content = parseContent({content: truncate ? ellipsize(about, 140) : about})
</script>
<p>
{#each content as { type, value }}
{#if type === "newline"}
{#each value as _}
<br />
{/each}
{:else if type === "link"}
<Anchor class="underline" external href={value.url}>
{value.url.replace(/https?:\/\/(www\.)?/, "")}
</Anchor>
{:else if type.startsWith("nostr:")}
<Anchor class="underline" external href={"/" + value.entity}>
{#if value.pubkey}
{displayGroup($group)}
{:else if value.id}
event {value.id}
{:else}
{value.entity.slice(0, 10) + "..."}
{/if}
</Anchor>
{:else}
{value}
{/if}
{/each}
</p>

View File

@ -0,0 +1,93 @@
<script lang="ts">
import Popover from "src/partials/Popover.svelte"
import OverflowMenu from "src/partials/OverflowMenu.svelte"
import {router} from "src/app/router"
import {
groups,
deriveAdminKeyForGroup,
leaveGroup,
joinGroup,
resetGroupAccess,
getGroupNaddr,
deriveGroupAccess,
} from "src/engine"
export let address
const group = groups.key(address)
const adminKey = deriveAdminKeyForGroup(address)
const access = deriveGroupAccess(address)
let actions = []
$: {
actions = []
actions.push({
onClick: () => router.at("qrcode").of(getGroupNaddr($group)).open(),
label: "Share",
icon: "share-nodes",
})
if ($adminKey) {
actions.push({
onClick: () => router.at("groups").of(address).at("edit").open(),
label: "Edit",
icon: "edit",
})
actions.push({
onClick: () => router.at("groups").of(address).at("rotate").open(),
label: "Rotate Keys",
icon: "rotate",
})
actions.push({
onClick: () => router.at("groups").of(address).at("info").open(),
label: "Details",
icon: "info",
})
}
}
const clear = () => resetGroupAccess(address)
const leave = () => leaveGroup(address)
const join = () => joinGroup(address)
</script>
<div class="flex items-center gap-3" on:click|stopPropagation>
{#if !$adminKey}
{#if !$access}
<Popover triggerType="mouseenter">
<div slot="trigger" class="w-6 text-center">
<i class="fa fa-right-to-bracket cursor-pointer" on:click={join} />
</div>
<div slot="tooltip">Join</div>
</Popover>
{:else if $access === "requested"}
<Popover triggerType="mouseenter">
<div slot="trigger" class="w-6 text-center">
<i class="fa fa-hourglass cursor-pointer" />
</div>
<div slot="tooltip">Access Pending</div>
</Popover>
{:else if $access === "granted"}
<Popover triggerType="mouseenter">
<div slot="trigger" class="w-6 text-center">
<i class="fa fa-right-from-bracket cursor-pointer" on:click={leave} />
</div>
<div slot="tooltip">Leave</div>
</Popover>
{:else if $access === "revoked"}
<Popover triggerType="mouseenter">
<div slot="trigger" class="w-6 text-center">
<i class="fa fa-times cursor-pointer" on:click={clear} />
</div>
<div slot="tooltip">Access Revoked</div>
</Popover>
{/if}
{/if}
<OverflowMenu {actions} />
</div>

View File

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

View File

@ -0,0 +1,107 @@
<script context="module" lang="ts">
export type Values = {
name: string
image: string
description: string
isPublic: boolean
relays: string[]
members?: Person[]
}
</script>
<script lang="ts">
import {pluck} from "ramda"
import {ucFirst} from "hurdak"
import {fly} from "src/util/transition"
import {toast} from "src/partials/state"
import Field from "src/partials/Field.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
import Toggle from "src/partials/Toggle.svelte"
import MultiSelect from "src/partials/MultiSelect.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
import Textarea from "src/partials/Textarea.svelte"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Heading from "src/partials/Heading.svelte"
import PersonMultiSelect from "src/app/shared/PersonMultiSelect.svelte"
import type {Person} from "src/engine"
import {searchRelays, normalizeRelayUrl} from "src/engine"
export let onSubmit
export let values: Values
export let mode = "create"
export let showMembers = false
const isAlreadyPublic = values.isPublic
const searchRelayUrls = q => pluck("url", $searchRelays(q))
const submit = async () => {
if (values.relays.length < 1) {
toast.show("error", "At least one relay is required.")
return
}
await onSubmit(values)
toast.show("info", "Your group has been saved!")
}
document.title = "Create Group"
</script>
<form on:submit|preventDefault={submit} in:fly={{y: 20}}>
<Content>
<div class="mb-4 flex flex-col items-center justify-center">
<Heading>{ucFirst(mode)} Group</Heading>
<p>Create a private place where members can talk.</p>
</div>
<div class="flex w-full flex-col gap-8">
<Field label="Name">
<Input bind:value={values.name}>
<i slot="before" class="fa fa-clipboard" />
</Input>
<div slot="info">The name of the group</div>
</Field>
<Field label="Picture">
<ImageInput
bind:value={values.image}
icon="image-portrait"
maxWidth={480}
maxHeight={480} />
<div slot="info">A picture for the group</div>
</Field>
<Field label="Description">
<Textarea bind:value={values.description} />
<div slot="info">The group's decription</div>
</Field>
<Field label="Relays">
<MultiSelect
search={searchRelayUrls}
bind:value={values.relays}
termToItem={normalizeRelayUrl}>
<i slot="before" class="fa fa-clipboard" />
</MultiSelect>
<div slot="info">
Which relays members should publish notes to. For additional privacy, select relays you
host yourself.
</div>
</Field>
{#if showMembers}
<Field label="Member List">
<PersonMultiSelect bind:value={values.members} />
<div slot="info">All members will receive a fresh invitation with a new key.</div>
</Field>
{/if}
<FieldInline label="Make Public">
<Toggle disabled={isAlreadyPublic} bind:value={values.isPublic} />
<div slot="info">
If enabled, this will generate a public listing for the group. The member list and group
messages will not be published.
</div>
</FieldInline>
<Anchor tag="button" theme="button" type="submit" class="text-center">Save</Anchor>
</div>
</Content>
</form>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import Card from "src/partials/Card.svelte"
import Anchor from "src/partials/Anchor.svelte"
import PersonSummary from "src/app/shared/PersonSummary.svelte"
import {session, deriveAdminKeyForGroup} from "src/engine"
import {router} from "src/app/router"
export let address
export let pubkey
const adminKey = deriveAdminKeyForGroup(address)
const remove = () =>
router
.at("groups")
.of(address)
.at("rotate")
.qp({removeMembers: [pubkey]})
.open()
const openPerson = pubkey => router.at("people").of(pubkey).open()
</script>
<Card interactive on:click={() => openPerson(pubkey)}>
<PersonSummary inert {pubkey}>
<div slot="actions" on:click|stopPropagation>
{#if $adminKey && pubkey !== $session.pubkey}
<Anchor on:click={remove} theme="button-accent">Remove</Anchor>
{/if}
</div>
</PersonSummary>
</Card>

View File

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

View File

@ -0,0 +1,67 @@
<script lang="ts">
import Card from "src/partials/Card.svelte"
import Chip from "src/partials/Chip.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import PersonBadgeSmall from "src/app/shared/PersonBadgeSmall.svelte"
import {groupRequests} from "src/engine"
import {router} from "src/app/router"
export let address
export let request
const dismiss = () => groupRequests.key(request.id).merge({resolved: true})
const resolve = () => {
if (request.kind === 25) {
router
.at("groups")
.of(address)
.at("rotate")
.qp({addMembers: [request.pubkey]})
.open()
}
if (request.kind === 26) {
router
.at("groups")
.of(address)
.at("rotate")
.qp({removeMembers: [request.pubkey]})
.open()
}
}
</script>
<Card interactive>
<Content>
<div class="flex items-center justify-between">
<p class="text-xl">
{#if request.kind === 25}
Request to join
{:else if request.kind === 26}
Key rotation request
{/if}
</p>
<div class="hidden gap-2 sm:flex">
<Anchor on:click={dismiss} theme="button">Dismiss</Anchor>
<Anchor on:click={resolve} theme="button-accent">Resolve</Anchor>
</div>
</div>
<p class="border-l-2 border-solid border-gray-5 pl-2">
"{request.content}"
</p>
<p>
Resolving this request will
{#if request.kind === 25}
add <Chip><PersonBadgeSmall pubkey={request.pubkey} /></Chip> to the group.
{:else if request.kind === 26}
remove <Chip><PersonBadgeSmall pubkey={request.pubkey} /></Chip> from the group.
{/if}
</p>
<div class="flex gap-2 sm:hidden">
<Anchor on:click={dismiss} theme="button">Dismiss</Anchor>
<Anchor on:click={resolve} theme="button-accent">Resolve</Anchor>
</div>
</Content>
</Card>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import GroupCircle from "src/app/shared/GroupCircle.svelte"
import GroupAbout from "src/app/shared/GroupAbout.svelte"
import GroupName from "src/app/shared/GroupName.svelte"
import {groups} from "src/engine"
export let address
const group = groups.key(address)
</script>
<div class="flex gap-4 text-gray-1">
<GroupCircle {address} class="h-8 w-8" />
<div class="flex min-w-0 flex-grow flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<GroupName class="text-2xl" {address} />
<slot name="actions" class="hidden xs:block" />
</div>
{#if $group?.description}
<GroupAbout {address} />
{/if}
</div>
</div>

View File

@ -1,11 +1,11 @@
<script lang="ts">
import {matchFilters} from "paravel"
import {reject, propEq, uniqBy, prop} from "ramda"
import {reject, whereEq, uniqBy, prop} from "ramda"
import {onMount, onDestroy} from "svelte"
import {quantify, batch} from "hurdak"
import {Tags} from "paravel"
import {fly} from "src/util/transition"
import {LOCAL_RELAY_URL, isLike} from "src/util/nostr"
import {isLike} from "src/util/nostr"
import {formatTimestamp} from "src/util/misc"
import Popover from "src/partials/Popover.svelte"
import Spinner from "src/partials/Spinner.svelte"
@ -20,6 +20,8 @@
import {
env,
load,
nip59,
groups,
people,
loadOne,
getLnUrl,
@ -31,7 +33,9 @@
getIdFilters,
getReplyFilters,
getSetting,
getRecipientKey,
selectHints,
displayGroup,
mergeHints,
loadPubkeys,
sortEventsDesc,
@ -49,8 +53,10 @@
export let showParent = true
export let showLoading = false
export let showMuted = false
export let showGroup = false
let zapper, unsubZapper
let ready = false
let event = note
let reply = null
let replyIsActive = false
@ -92,7 +98,7 @@
.open()
const removeFromContext = e => {
ctx = reject(propEq("id", e.id), ctx)
ctx = reject(whereEq({id: e.id}), ctx)
}
$: tags = Tags.from(event).normalize()
@ -161,28 +167,32 @@
if (!event.pubkey) {
event = await loadOne({
relays: selectHints(relays).concat(LOCAL_RELAY_URL),
relays: selectHints(relays),
filters: getIdFilters([event.id]),
})
}
if (event.pubkey) {
const hints = getReplyHints(event)
if (event.kind === 1059) {
event = await nip59.get().unwrap(event, getRecipientKey(event))
}
ready = true
if (event.pubkey) {
loadPubkeys([event.pubkey])
const kinds = [1]
if (getSetting('enable_reactions')) {
if (getSetting("enable_reactions")) {
kinds.push(7)
}
if ($env.ENABLE_ZAPS) {
if ($env.ENABLE_ZAPS && !event.wrap) {
kinds.push(9735)
}
load({
relays: mergeHints([relays, hints]).concat(LOCAL_RELAY_URL),
relays: mergeHints([relays, getReplyHints(event)]),
filters: getReplyFilters([event], {kinds}),
onEvent: batch(200, events => {
ctx = uniqBy(prop("id"), ctx.concat(events))
@ -196,11 +206,22 @@
})
</script>
{#if event.pubkey}
{#if ready}
{@const address = tags.getCommunity()}
{@const path = router
.at("notes")
.of(event.id, {relays: getEventHints(event)})
.toString()}
{#if address && showGroup}
<p class="py-2 text-gray-3">
Posted in +<Anchor
modal
theme="anchor"
href={router.at("groups").of(address).at("notes").toString()}>
{displayGroup(groups.key(address).get())}
</Anchor>
</p>
{/if}
<div class="note relative" class:py-2={!showParent && !topLevel}>
{#if !showParent && !topLevel}
<div class="absolute -left-4 h-px w-4 bg-gray-6" style="top: 27px;" />

View File

@ -1,11 +1,11 @@
<script lang="ts">
import cx from "classnames"
import {nip19} from "nostr-tools"
import {toNostrURI} from "paravel"
import {toNostrURI, createEvent} from "paravel"
import {tweened} from "svelte/motion"
import {find, pathEq, identity, propEq, sum, pluck, sortBy} from "ramda"
import {identity, sum, pluck, sortBy} from "ramda"
import {formatSats} from "src/util/misc"
import {LOCAL_RELAY_URL, asNostrEvent, getIdOrNaddr} from "src/util/nostr"
import {LOCAL_RELAY_URL, getGroupAddress, asNostrEvent, getIdOrAddress} from "src/util/nostr"
import {quantify} from "hurdak"
import {toast} from "src/partials/state"
import Popover from "src/partials/Popover.svelte"
@ -25,15 +25,18 @@
canSign,
session,
Publisher,
signer,
deriveGroupAccess,
publishToZeroOrMoreGroups,
publishDeletion,
getUserRelayUrls,
getPublishHints,
publishReaction,
getSetting,
processZap,
displayRelay,
getEventHints,
isEventMuted,
getReplyTags,
} from "src/engine"
export let note: Event
@ -46,7 +49,8 @@
export let zaps
export let zapper
const nevent = nip19.neventEncode({id: note.id, relays: getEventHints(note)})
const relays = getEventHints(note)
const nevent = nip19.neventEncode({id: note.id, relays})
const muted = isEventMuted.derived($isEventMuted => $isEventMuted(note, true))
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const likesCount = tweened(0, {interpolate})
@ -55,31 +59,29 @@
//const report = () => router.at("notes").of(note.id, {relays: getEventHints(note)}).at('report').qp({pubkey: note.pubkey}).open()
const label = () =>
router
.at("notes")
.of(note.id, {relays: getEventHints(note)})
.at("label")
.open()
const label = () => router.at("notes").of(note.id, {relays}).at("label").open()
const quote = () =>
router
.at("notes/create")
.cx({quote: note, relays: getEventHints(note)})
.open()
const quote = () => router.at("notes/create").cx({quote: note, relays}).open()
const unmuteNote = () => unmute(note.id)
const muteNote = () => mute("e", note.id)
const react = async content => {
const pub = await publishReaction(note, content)
const relays = getPublishHints(note)
const template = createEvent(7, {content, tags: getReplyTags(note)})
like = pub.event
if (!note.wrap) {
Publisher.publish({relays, event: asNostrEvent(note)})
}
publishToZeroOrMoreGroups([address], template, {relays, shouldWrap: Boolean(note.wrap)})
like = await signer.get().signAsUser(template)
}
const deleteReaction = e => {
publishDeletion([getIdOrNaddr(e)])
publishDeletion([getIdOrAddress(e)])
like = null
removeFromContext(e)
@ -112,21 +114,24 @@
let showDetails = false
let actions = []
$: disableActions = !$canSign || ($muted && !showMuted)
$: like = like || find(propEq("pubkey", $session?.pubkey), likes)
$: address = getGroupAddress(note)
$: disableActions =
!$canSign ||
($muted && !showMuted) ||
(note.wrap && deriveGroupAccess(address).get() !== "granted")
$: like = like || likes.find(e => e.pubkey === $session?.pubkey)
$: allLikes = like ? likes.filter(n => n.id !== like?.id).concat(like) : likes
$: $likesCount = allLikes.length
$: zap = zap || find(pathEq($session?.pubkey, ["request", "pubkey"]), zaps)
$: zap = zap || zaps.find(e => e.request.pubkey === $session?.pubkey, zaps)
$: $zapsTotal =
sum(
pluck(
// @ts-ignore
"invoiceAmount",
zap ? zaps.filter(n => n.id !== zap?.id).concat(processZap(zap, zapper)) : zaps
)
) / 1000
$: {
const filteredZaps: {invoiceAmount: number}[] = zap
? zaps.filter(n => n.id !== zap?.id).concat(processZap(zap, zapper))
: zaps
$zapsTotal = sum(pluck("invoiceAmount", filteredZaps)) / 1000
}
$: canZap = zapper && note.pubkey !== $session?.pubkey
$: $repliesCount = replies.length
@ -146,6 +151,7 @@
if ($env.FORCE_RELAYS.length === 0) {
actions.push({label: "Broadcast", icon: "rss", onClick: broadcast})
}
actions.push({
label: "Details",
@ -155,7 +161,6 @@
},
})
}
}
</script>
<div class="flex justify-between text-gray-1" on:click|stopPropagation>
@ -168,7 +173,7 @@
<i class="fa fa-reply cursor-pointer" />
{$repliesCount}
</button>
{#if getSetting('enable_reactions')}
{#if getSetting("enable_reactions")}
<button
class={cx("relative w-16 pt-1 text-left transition-all hover:pb-1 hover:pt-0", {
"pointer-events-none opacity-50": disableActions || note.pubkey === $session?.pubkey,
@ -182,7 +187,7 @@
{$likesCount}
</button>
{/if}
{#if $env.ENABLE_ZAPS}
{#if $env.ENABLE_ZAPS && !note.wrap}
<button
class={cx("relative w-16 pt-1 text-left transition-all hover:pb-1 hover:pt-0 sm:w-20", {
"pointer-events-none opacity-50": disableActions || !canZap,
@ -274,7 +279,7 @@
<h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Link" value={toNostrURI(nevent)} />
<CopyValue label="Event ID" encode={nip19.noteEncode} value={note.id} />
<CopyValue label="Event JSON" value={JSON.stringify(note)} />
<CopyValue label="Event JSON" value={JSON.stringify(asNostrEvent(note))} />
</Content>
</Modal>
{/if}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import {Tags} from "paravel"
import {urlIsMedia} from "src/util/notes"
import {Naddr} from "src/util/nostr"
import Card from "src/partials/Card.svelte"
import Chip from "src/partials/Chip.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -8,13 +9,13 @@
import NoteContentLink from "src/app/shared/NoteContentLink.svelte"
import NoteContentTopics from "src/app/shared/NoteContentTopics.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {Naddr} from "src/engine"
import {getEventHints} from "src/engine"
export let note
export let showMedia = false
const tags = Tags.from(note)
const naddr = Naddr.fromEvent(note).encode()
const naddr = Naddr.fromEvent(note, getEventHints(note)).encode()
const {title, summary, image, status, p} = tags.getDict() as Record<string, string>
</script>

View File

@ -21,7 +21,7 @@
export let note
export let value
let quote = null
let quote
let muted = false
let loading = true
@ -32,7 +32,7 @@
const relays = selectHints([...hints, ...getParentHints(note)])
const openQuote = e => {
const noteId = id || quote?.id
const noteId = value.id || quote?.id
// stopPropagation wasn't working for some reason
if (noteId && e.detail.target.textContent !== "Show") {

View File

@ -0,0 +1,194 @@
<script lang="ts">
import {without} from "ramda"
import {createEventDispatcher} from "svelte"
import Anchor from "src/partials/Anchor.svelte"
import Card from "src/partials/Card.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
import Toggle from "src/partials/Toggle.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
import Input from "src/partials/Input.svelte"
import Field from "src/partials/Field.svelte"
import Heading from "src/partials/Heading.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import GroupSummary from "src/app/shared/GroupSummary.svelte"
import RelaySearch from "src/app/shared/RelaySearch.svelte"
import {mergeHints, displayRelay, getGroupRelayUrls} from "src/engine"
import {env, groups, getUserRelayUrls, deriveGroupAccess} from "src/engine"
export let groupOptions = []
export let showRelays = $env.FORCE_RELAYS.length === 0
export let initialValues: {
warning: string
groups: string[]
relays: string[]
anonymous: boolean
shouldWrap: boolean
}
let values = {...initialValues}
let view = null
let relaySearch = ""
let relaysDirty = false
let canPostPrivately = false
let canPostPublicly = false
const dispatch = createEventDispatcher()
export const setView = name => {
view = name
if (!view) {
relaySearch = ""
values = {...initialValues}
}
}
const addRelay = url => {
relaySearch = ""
values.relays = values.relays.concat(url)
relaysDirty = true
}
const removeRelay = url => {
values.relays = without([url], values.relays)
relaysDirty = true
}
const setGroup = address => {
// Reset this, it'll get reset reactively below
values.shouldWrap = true
if (values.groups.includes(address)) {
values.groups = without([address], values.groups)
} else {
values.groups = values.groups.concat(address)
}
if (!relaysDirty) {
if (values.groups.length > 0) {
values.relays = mergeHints(values.groups.map(getGroupRelayUrls))
} else {
values.relays = getUserRelayUrls("write")
}
}
}
const onSubmit = () => {
if (canPostPrivately || canPostPublicly) {
initialValues = values
dispatch("change", values)
setView(null)
}
}
$: {
canPostPrivately = values.groups.length > 0
canPostPublicly = true
for (const address of values.groups) {
const group = groups.key(address).get()
const access = deriveGroupAccess(address).get()
if (group.access === "open" || access !== "granted") {
canPostPrivately = false
} else if (group.access === "closed") {
canPostPublicly = false
}
}
values.shouldWrap = values.shouldWrap && canPostPrivately
dispatch("change", {shouldWrap: values.shouldWrap})
}
</script>
{#if view}
<Modal onEscape={() => setView(null)}>
<form on:submit|preventDefault={onSubmit}>
<Content>
{#if view === "settings"}
<div class="mb-4 flex items-center justify-center">
<Heading>Note settings</Heading>
</div>
<Field icon="fa-warning" label="Content warnings">
<Input
bind:value={values.warning}
placeholder="Why might people want to skip this post?" />
</Field>
{#if showRelays}
<Field icon="fa-database" label="Select which relays to publish to">
<div>
{#each values.relays as url}
<div
class="mb-2 mr-1 inline-block rounded-full border border-solid border-gray-1 px-2 py-1">
<button
type="button"
class="fa fa-times cursor-pointer"
on:click={() => removeRelay(url)} />
{displayRelay({url})}
</div>
{/each}
</div>
<RelaySearch bind:q={relaySearch} limit={3} hideIfEmpty>
<div slot="item" let:relay>
<RelayCard {relay}>
<button
slot="actions"
class="underline"
on:click|preventDefault={() => addRelay(relay.url)}>
Add relay
</button>
</RelayCard>
</div>
</RelaySearch>
</Field>
{/if}
<FieldInline icon="fa-user-secret" label="Post anonymously">
<Toggle bind:value={values.anonymous} />
<p slot="info">Enable this to create an anonymous note.</p>
</FieldInline>
<Anchor tag="button" theme="button" type="submit" class="w-full text-center">Done</Anchor>
{:else if view === "groups"}
<div class="mb-4 flex items-center justify-center">
<Heading>Post to a group</Heading>
</div>
{#if canPostPrivately && canPostPublicly}
<FieldInline label="Post privately">
<Toggle bind:value={values.shouldWrap} />
<p slot="info">
When enabled, your note will only be visible to other members of the group.
</p>
</FieldInline>
{/if}
<div>Select any groups you'd like to post to:</div>
<div class="flex flex-col gap-2">
{#each groupOptions as g (g.address)}
<Card invertColors interactive on:click={() => setGroup(g.address)}>
<GroupSummary address={g.address}>
<div slot="actions">
{#if values.groups.includes(g.address)}
<i class="fa fa-circle-check text-accent" />
{/if}
</div>
</GroupSummary>
</Card>
{/each}
</div>
{#if !canPostPrivately && !canPostPublicly}
<p class="rounded-full border border-solid border-danger bg-gray-8 px-4 py-2">
You have selected a mix of public and private groups. Please choose one or the other.
</p>
{/if}
<Anchor
tag="button"
theme="button"
type="submit"
class="text-center"
disabled={!canPostPrivately && !canPostPublicly}>Done</Anchor>
{/if}
</Content>
</form>
</Modal>
{/if}

View File

@ -1,13 +1,23 @@
<script lang="ts">
import {Tags} from "paravel"
import {createEventDispatcher} from "svelte"
import {without, uniq} from "ramda"
import {without, identity, uniq} from "ramda"
import {getGroupAddress, asNostrEvent} from "src/util/nostr"
import {slide} from "src/util/transition"
import ImageInput from "src/partials/ImageInput.svelte"
import Chip from "src/partials/Chip.svelte"
import Media from "src/partials/Media.svelte"
import Compose from "src/app/shared/Compose.svelte"
import {publishReply, session, displayPubkey, mention} from "src/engine"
import NoteOptions from "src/app/shared/NoteOptions.svelte"
import {
Publisher,
buildReply,
publishToZeroOrMoreGroups,
session,
getPublishHints,
displayPubkey,
mention,
} from "src/engine"
import {toastProgress} from "src/app/state"
export let parent
@ -19,6 +29,14 @@
let reply = null
let container = null
let draft = ""
let options
let opts = {
warning: "",
groups: parent.wrap ? [Tags.from(parent).getCommunity()] : [],
shouldWrap: Boolean(parent.wrap),
relays: getPublishHints(parent),
anonymous: false,
}
export const start = () => {
dispatch("start")
@ -34,6 +52,10 @@
setTimeout(() => reply.write(draft))
}
const setOpts = e => {
opts = {...opts, ...e.detail}
}
const saveDraft = () => {
if (reply) {
draft = reply.parse()
@ -59,20 +81,32 @@
const send = async () => {
const content = getContent()
if (!content) {
return
}
const tags = data.mentions.map(mention)
if (content) {
const pub = await publishReply(parent, content, tags)
if (opts.warning) {
tags.push(["content-warning", opts.warning])
}
dispatch("event", pub.event)
// Re-broadcast the note we're replying to
if (!opts.shouldWrap) {
Publisher.publish({relays: opts.relays, event: asNostrEvent(parent)})
}
pub.on("progress", toastProgress)
const template = buildReply(parent, content, tags)
const addresses = [getGroupAddress(parent)].filter(identity)
const pubs = await publishToZeroOrMoreGroups(addresses, template, opts)
pubs[0].on("progress", toastProgress)
clearDraft()
reset()
}
}
const onBodyClick = e => {
const target = e.target as HTMLElement
@ -123,6 +157,7 @@
<ImageInput bind:value={data.image}>
<i slot="button" class="fa fa-paperclip" />
</ImageInput>
<i class="fa fa-cog" on:click={() => options.setView("settings")} />
<i class="fa fa-at" />
</div>
</div>
@ -138,10 +173,7 @@
</div>
</div>
</div>
<div class="flex justify-end gap-2 text-sm text-gray-5">
<span>
Posting as @{displayPubkey($session.pubkey)}
</span>
</div>
</div>
{/if}
<NoteOptions bind:this={options} on:change={setOpts} initialValues={opts} showRelays={!opts.shouldWrap} />

View File

@ -1,12 +1,12 @@
<script lang="ts">
import {last, prop} from "ramda"
import {last} from "ramda"
import OverflowMenu from "src/partials/OverflowMenu.svelte"
import {canSign, relays, relayPolicyUrls, joinRelay, leaveRelay, deriveHasRelay} from "src/engine"
import {router} from "src/app/router"
export let relay
const info = relays.key(relay.url).derived(prop("info"))
const info = relays.key(relay.url).derived(r => r.info)
const joined = deriveHasRelay(relay.url)
let actions = []

View File

@ -1,9 +1,11 @@
<script lang="ts">
import {nip19} from "nostr-tools"
import {Naddr} from "src/util/nostr"
import Content from "src/partials/Content.svelte"
import NoteDetail from "src/app/views/NoteDetail.svelte"
import RelayDetail from "src/app/views/RelayDetail.svelte"
import PersonDetail from "src/app/views/PersonDetail.svelte"
import GroupDetail from "src/app/views/GroupDetail.svelte"
export let entity, type, data, relays
</script>
@ -13,7 +15,11 @@
{:else if type === "note"}
<NoteDetail eid={data} {relays} />
{:else if type === "naddr"}
{#if data.kind === 34550}
<GroupDetail address={Naddr.decode(entity).asTagValue()} activeTab="notes" />
{:else}
<NoteDetail {...data} />
{/if}
{:else if type === "nrelay"}
<RelayDetail url={data} />
{:else if type === "nprofile"}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {complement, prop, filter} from "ramda"
import {filter} from "ramda"
import {toTitle} from "hurdak"
import Tabs from "src/partials/Tabs.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -9,6 +9,7 @@
import ForegroundButtons from "src/partials/ForegroundButtons.svelte"
import ChannelsListItem from "src/app/views/ChannelsListItem.svelte"
import {router} from "src/app/router"
import type {Channel} from "src/engine"
import {
nip24Channels,
hasNewNip24Messages,
@ -18,8 +19,8 @@
} from "src/engine"
const activeTab = window.location.pathname.slice(1) === "channels" ? "conversations" : "requests"
const accepted = nip24Channels.derived(filter(prop("last_sent")))
const requests = nip24Channels.derived(filter(complement(prop("last_sent"))))
const accepted = nip24Channels.derived(filter((c: Channel) => Boolean(c.last_sent)))
const requests = nip24Channels.derived(filter((c: Channel) => !c.last_sent))
const setActiveTab = tab => {
const path = tab === "requests" ? "channels/requests" : "channels"

View File

@ -59,7 +59,7 @@
</Content>
{/if}
{#key key}
<Feed {filter} {relays}>
<Feed showGroup {filter} {relays}>
<div slot="controls">
{#if $canSign}
{#if $userLists.length > 0}

View File

@ -0,0 +1,29 @@
<script lang="ts">
import {pluck} from "ramda"
import type {Values} from "src/app/shared/GroupDetailsForm.svelte"
import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte"
import {publishGroupMeta, publishGroupInvites, initGroup, user} from "src/engine"
import {router} from "src/app/router"
const initialValues = {
name: "",
image: "",
description: "",
isPublic: false,
members: [$user],
relays: [],
}
const onSubmit = async (values: Values) => {
const members = pluck("pubkey", values.members)
const access = values.isPublic ? "hybrid" : "closed"
const {id, address} = initGroup(members, values.relays)
await publishGroupInvites(address, members, values.relays)
await publishGroupMeta(address, {...values, access, id})
router.at("groups").of(address).at("members").replace()
}
</script>
<GroupDetailsForm {onSubmit} showMembers values={initialValues} />

View File

@ -0,0 +1,133 @@
<script>
import {onMount} from "svelte"
import {whereEq, without, uniq} from "ramda"
import {noteKinds} from "src/util/nostr"
import {getKey} from "src/util/router"
import {getThemeBackgroundGradient} from "src/partials/state"
import Content from "src/partials/Content.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Anchor from "src/partials/Anchor.svelte"
import GroupCircle from "src/app/shared/GroupCircle.svelte"
import GroupActions from "src/app/shared/GroupActions.svelte"
import GroupAbout from "src/app/shared/GroupAbout.svelte"
import GroupRequest from "src/app/shared/GroupRequest.svelte"
import GroupMember from "src/app/shared/GroupMember.svelte"
import Feed from "src/app/shared/Feed.svelte"
import {
displayGroup,
groups,
subscribe,
joinGroup,
groupRequests,
getGroupReqInfo,
deriveAdminKeyForGroup,
deriveSharedKeyForGroup,
getRelaysFromFilters,
deriveGroupAccess,
} from "src/engine"
import {router} from "src/app/router"
export let address, activeTab
const group = groups.key(address)
const {rgb, rgba} = getThemeBackgroundGradient()
const access = deriveGroupAccess(address)
const sharedKey = deriveSharedKeyForGroup(address)
const adminKey = deriveAdminKeyForGroup(address)
const filter = {kinds: noteKinds, "#a": [address]}
const relays = getRelaysFromFilters([filter])
const requests = groupRequests.derived(requests =>
requests.filter(whereEq({group: address, resolved: false}))
)
const setActiveTab = tab =>
router
.at("groups")
.of(address)
.at(tab)
.push({key: getKey(router.current.get())})
onMount(() => {
const {recipients, relays} = getGroupReqInfo(address)
const sub = subscribe({relays, filters: [{kinds: [1059], "#p": recipients}]})
return () => sub.close()
})
$: members = uniq(
without([$group?.pubkey], ($sharedKey?.members || []).concat($adminKey?.members || []))
)
let tabs
$: {
tabs = ["notes"]
if ($sharedKey) {
tabs.push("members")
} else if (activeTab === "members") {
activeTab = "notes"
}
if ($adminKey) {
tabs.push("admin")
} else if (activeTab === "admin") {
activeTab = "notes"
}
}
document.title = $group?.name || "Group Detail"
</script>
<div
class="absolute left-0 h-64 w-full"
style={`z-index: -1;
background-size: cover;
background-image: linear-gradient(to bottom, ${rgba}, ${rgb}), url('${$group?.meta?.banner}')`} />
<Content>
<div class="flex gap-4 text-gray-1">
<GroupCircle {address} class="mt-1 h-12 w-12 sm:h-32 sm:w-32" />
<div class="flex min-w-0 flex-grow flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<span class="text-2xl">{displayGroup($group)}</span>
<div class="hidden xs:block">
<GroupActions {address} />
</div>
</div>
<GroupAbout {address} />
</div>
</div>
{#if tabs.length > 1}
<Tabs {tabs} {activeTab} {setActiveTab} />
{/if}
{#if (!$group?.access || $group?.access === "closed") && $access !== "granted"}
<p class="m-auto max-w-sm py-12 text-center">
{#if $access === "requested"}
Your access request is awaiting approval.
{:else}
You don't have access to this group.
{/if}
{#if !$access}
Click <Anchor theme="anchor" on:click={() => joinGroup(address)}>here</Anchor> to request entry.
{/if}
</p>
{:else if activeTab === "notes"}
<Feed hideControls {filter} {relays} />
{:else if activeTab === "members"}
{#each members as pubkey (pubkey)}
<GroupMember {address} {pubkey} />
{:else}
<p class="text-center py-12">No members found.</p>
{/each}
{:else if activeTab === "admin"}
{#each $requests as request (request.id)}
<GroupRequest {address} {request} />
{:else}
<p class="text-center py-12">No action items found.</p>
{/each}
{/if}
</Content>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import {toast} from "src/partials/state"
import type {Values} from "src/app/shared/GroupDetailsForm.svelte"
import GroupDetailsForm from "src/app/shared/GroupDetailsForm.svelte"
import {groups, publishGroupMeta, getGroupId, getGroupName} from "src/engine"
import {router} from "src/app/router"
export let address
const group = groups.key(address)
const initialValues = {
id: getGroupId($group),
name: getGroupName($group),
image: $group.image || "",
description: $group.description || "",
isPublic: $group.access !== "closed",
relays: $group.relays || [],
}
const onSubmit = async (values: Values) => {
const access = values.isPublic ? "hybrid" : "closed"
const pub = await publishGroupMeta(address, {...values, access})
await pub.result
toast.show("info", "Your group has been updated!")
router.pop()
}
</script>
<GroupDetailsForm {onSubmit} mode="edit" values={initialValues} />

View File

@ -0,0 +1,28 @@
<script lang="ts">
import {toNostrURI} from "paravel"
import Content from "src/partials/Content.svelte"
import Popover from "src/partials/Popover.svelte"
import CopyValue from "src/partials/CopyValue.svelte"
import {groups, deriveAdminKeyForGroup, getGroupNaddr} from "src/engine"
export let address
const group = groups.key(address)
const adminKey = deriveAdminKeyForGroup(address)
</script>
<Content>
<h1 class="staatliches text-2xl">Details</h1>
<CopyValue label="Link" value={toNostrURI(getGroupNaddr($group))} />
{#if $adminKey}
<CopyValue isPassword label="Admin key" value={$adminKey.privkey}>
<div slot="label" class="flex gap-2">
<span>Admin Key</span>
<Popover triggerType="mouseenter">
<i slot="trigger" class="fa fa-info-circle cursor-pointer" />
<span slot="tooltip">This is your group administration password. Keep it secret!</span>
</Popover>
</div>
</CopyValue>
{/if}
</Content>

View File

@ -0,0 +1,86 @@
<script>
import {onMount, onDestroy} from "svelte"
import {derived} from "svelte/store"
import {partition} from "ramda"
import {fuzzy, createScroller} from "src/util/misc"
import {getModal} from "src/partials/state"
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Input from "src/partials/Input.svelte"
import GroupListItem from "src/app/views/GroupListItem.svelte"
import {
load,
groups,
getUserRelayUrls,
mergeHints,
getGroupReqInfo,
deriveGroupAccess,
session,
} from "src/engine"
const loadMore = async () => {
limit += 50
}
const scroller = createScroller(loadMore, {element: getModal()})
const groupList = derived([groups, session], ([$groups, $session]) => {
const [joined, other] = partition(g => deriveGroupAccess(g.address).get(), $groups)
return {joined, other}
})
let q = ""
let limit = 50
$: searchGroups = fuzzy($groupList.other, {
keys: [{name: "id", weight: 0.2}, "name", "description"],
})
document.title = "Groups"
onMount(() => {
const {admins, recipients, relays} = getGroupReqInfo()
load({
relays: mergeHints([relays, getUserRelayUrls("read")]),
filters: [{kinds: [1059], "#p": recipients, limit: 1000}],
})
load({
relays: getUserRelayUrls("read"),
filters: [
{kinds: [34550], authors: admins},
{kinds: [34550], limit: 20},
],
})
})
onDestroy(() => {
scroller.stop()
})
</script>
<Content>
<div class="flex justify-between">
<div class="flex items-center gap-2">
<i class="fa fa-circle-nodes fa-lg" />
<h2 class="staatliches text-2xl">Your groups</h2>
</div>
<Anchor modal theme="button-accent" href="/groups/new">
<i class="fa-solid fa-plus" /> Create Group
</Anchor>
</div>
{#each $groupList.joined as group (group.address)}
<GroupListItem {group} />
{:else}
<p class="text-center py-8">You haven't yet joined any groups.</p>
{/each}
<div class="mb-2 border-b border-solid border-gray-6 pt-2" />
<Input bind:value={q} type="text" wrapperClass="flex-grow" placeholder="Search groups">
<i slot="before" class="fa-solid fa-search" />
</Input>
{#each searchGroups(q).slice(0, limit) as group (group.address)}
<GroupListItem {group} />
{/each}
</Content>

View File

@ -0,0 +1,25 @@
<script>
import {ellipsize} from "hurdak"
import Card from "src/partials/Card.svelte"
import GroupCircle from "src/app/shared/GroupCircle.svelte"
import {displayGroup} from "src/engine"
import {router} from "src/app/router"
export let group
const enter = () => router.at("groups").of(group.address).at("notes").push()
</script>
<Card interactive on:click={enter} class="flex gap-4">
<GroupCircle class="h-14 w-14" address={group.address} />
<div class="flex min-w-0 flex-grow flex-col justify-start gap-1">
<h2 class="text-xl font-bold">
{displayGroup(group)}
</h2>
{#if group.about}
<p class="text-start text-gray-1">
{ellipsize(group.about, 300)}
</p>
{/if}
</div>
</Card>

View File

@ -0,0 +1,98 @@
<script lang="ts">
import {pluck, assoc, uniq, without} from "ramda"
import {quantify, difference} from "hurdak"
import {toast} from "src/partials/state"
import Field from "src/partials/Field.svelte"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Heading from "src/partials/Heading.svelte"
import PersonMultiSelect from "src/app/shared/PersonMultiSelect.svelte"
import type {GroupRequest} from "src/engine"
import {
people,
groups,
groupRequests,
initSharedKey,
deriveAdminKeyForGroup,
groupAdminKeys,
publishGroupInvites,
publishGroupEvictions,
publishGroupMeta,
} from "src/engine"
import {router} from "src/app/router"
export let address
export let addMembers = []
export let removeMembers = []
const group = groups.key(address)
const adminKey = deriveAdminKeyForGroup(address)
const initialMembers = uniq(without(removeMembers, [...$adminKey.members, ...addMembers]))
const onSubmit = () => {
initSharedKey(address)
const newMembers = pluck("pubkey", members)
const removedMembers = Array.from(difference(new Set(initialMembers), new Set(newMembers)))
const gracePeriod = graceHours * 60 * 60
// Update our authoritative member list
groupAdminKeys.key($adminKey.pubkey).update(assoc("members", newMembers))
// Clear any requests
groupRequests.update($requests => {
return $requests.map((r: GroupRequest) => {
if (r.group !== address) {
return r
}
if (r.kind === 25 && newMembers.includes(r.pubkey)) {
return {...r, resolved: true}
}
if (r.kind === 26 && !newMembers.includes(r.pubkey)) {
return {...r, resolved: true}
}
return r
})
})
// Send new invites
publishGroupInvites(address, newMembers, gracePeriod)
// Send evictions
publishGroupEvictions(address, removedMembers, gracePeriod)
// Re-publish group info
publishGroupMeta(address, $group)
toast.show("info", "Invites have been sent!")
router.pop()
}
let graceHours = 24
let members = people.mapStore
.derived(m => initialMembers.map(pubkey => m.get(pubkey) || {pubkey}))
.get()
</script>
<form on:submit|preventDefault={onSubmit}>
<Content size="lg">
<Heading class="text-center">Rotate Keys</Heading>
<p class="text-center">
Rotate keys periodically to change group membership and increase security.
</p>
<Field label="Member List">
<PersonMultiSelect bind:value={members} />
<div slot="info">All members will receive a fresh invitation with a new key.</div>
</Field>
<Field label="Grace Period">
<div slot="display">{quantify(graceHours, "hour")}</div>
<Input type="range" bind:value={graceHours} min={0} max={72} />
<div slot="info">Set how long the old key will still be valid for posting to the group.</div>
</Field>
<Anchor tag="button" theme="button" type="submit" class="text-center">Save</Anchor>
</Content>
</form>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import {Tags} from "paravel"
import {randomId} from "hurdak"
import {Naddr} from "src/util/nostr"
import {toast} from "src/partials/state"
import Heading from "src/partials/Heading.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
@ -10,7 +11,6 @@
import MultiSelect from "src/partials/MultiSelect.svelte"
import {router} from "src/app/router"
import {
Naddr,
userLists,
searchPeople,
searchTopics,

View File

@ -1,11 +1,12 @@
<script type="ts">
import {Naddr} from "src/util/nostr"
import {appName} from "src/partials/state"
import Heading from "src/partials/Heading.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import ListSummary from "src/app/shared/ListSummary.svelte"
import {router} from "src/app/router"
import {Naddr, userLists, publishDeletion} from "src/engine"
import {userLists, publishDeletion} from "src/engine"
const createFeed = () => router.at("lists/create").open()

View File

@ -1,10 +1,11 @@
<script lang="ts">
import {filter, complement, prop} from "ramda"
import {filter} from "ramda"
import {toTitle} from "hurdak"
import Tabs from "src/partials/Tabs.svelte"
import Popover from "src/partials/Popover.svelte"
import Content from "src/partials/Content.svelte"
import MessagesListItem from "src/app/views/MessagesListItem.svelte"
import type {Channel} from "src/engine"
import {
nip04Channels,
hasNewNip04Messages,
@ -16,8 +17,8 @@
const activeTab =
window.location.pathname.slice(1) === "conversations" ? "conversations" : "requests"
const accepted = nip04Channels.derived(filter(prop("last_sent")))
const requests = nip04Channels.derived(filter(complement(prop("last_sent"))))
const accepted = nip04Channels.derived(filter((c: Channel) => Boolean(c.last_sent)))
const requests = nip04Channels.derived(filter((c: Channel) => !c.last_sent))
const setActiveTab = tab => {
const path = tab === "requests" ? "conversations/requests" : "conversations"
@ -41,7 +42,7 @@
</div>
</div>
</Tabs>
{#if activeTab === 'conversations'}
{#if activeTab === "conversations"}
<Popover triggerType="mouseenter" class="absolute right-7 top-7 hidden sm:block">
<div slot="trigger">
<i

View File

@ -1,9 +1,9 @@
<script lang="ts">
import {onMount} from "svelte"
import {nip19} from "nostr-tools"
import {without} from "ramda"
import {throttle} from "hurdak"
import {writable} from "svelte/store"
import {without, identity, prop, uniqBy} from "ramda"
import {throttle, quantify} from "hurdak"
import {createEvent, Tags} from "paravel"
import {annotateMedia} from "src/util/misc"
import {asNostrEvent} from "src/util/nostr"
import Anchor from "src/partials/Anchor.svelte"
@ -11,30 +11,60 @@
import ImageInput from "src/partials/ImageInput.svelte"
import Media from "src/partials/Media.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
import Input from "src/partials/Input.svelte"
import Field from "src/partials/Field.svelte"
import Heading from "src/partials/Heading.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import RelaySearch from "src/app/shared/RelaySearch.svelte"
import {Publisher, publishNote, displayRelay, getUserRelayUrls, mention} from "src/engine"
import NoteOptions from "src/app/shared/NoteOptions.svelte"
import {Publisher, mention} from "src/engine"
import {toastProgress} from "src/app/state"
import {router} from "src/app/router"
import {session, getEventHints, displayPubkey} from "src/engine"
import {
session,
getEventHints,
displayGroup,
groups,
getUserRelayUrls,
publishToZeroOrMoreGroups,
} from "src/engine"
export let quote = null
export let pubkey = null
export let writeTo: string[] | null = null
export let group = null
let q = ""
let images = []
let warning = null
let compose = null
let wordCount = 0
let showPreview = false
let showSettings = false
let relays = writable(writeTo ? writeTo : getUserRelayUrls("write"))
let defaultGroup = quote ? Tags.from(quote).getCommunity() : group
let options
let opts = {
warning: "",
groups: [defaultGroup].filter(identity),
relays: getUserRelayUrls("write"),
anonymous: false,
shouldWrap: true,
}
const setOpts = e => {
opts = {...opts, ...e.detail}
}
const groupOptions = session.derived($session => {
const options = []
for (const address of Object.keys($session.groups || {})) {
const group = groups.key(address).get()
if (group) {
options.push(group)
}
}
if (defaultGroup) {
options.push({address: defaultGroup})
}
return uniqBy(prop("address"), options)
})
const onSubmit = async () => {
const tags = []
@ -44,23 +74,26 @@
return
}
if (warning) {
tags.push(["content-warning", warning])
if (opts.warning) {
tags.push(["content-warning", opts.warning])
}
if (quote) {
tags.push(mention(quote.pubkey))
// Re-broadcast the note we're quoting
if (!opts.groups.length) {
Publisher.publish({
relays: $relays,
relays: opts.relays,
event: asNostrEvent(quote),
})
}
}
const pub = await publishNote(content, tags, $relays)
const template = createEvent(1, {content, tags})
const pubs = await publishToZeroOrMoreGroups(opts.groups, template, opts)
pub.on("progress", toastProgress)
pubs[0].on("progress", toastProgress)
router.clearModals()
}
@ -79,20 +112,6 @@
images = without([url], images)
}
const closeSettings = () => {
q = ""
showSettings = false
}
const saveRelay = url => {
q = ""
relays.update($r => $r.concat(url))
}
const removeRelay = url => {
relays.update(without([url]))
}
const togglePreview = () => {
showPreview = !showPreview
}
@ -121,7 +140,22 @@
<Heading class="text-center">Create a note</Heading>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<strong>What do you want to say?</strong>
{#if $groupOptions.length > 0}
<div
class="flex items-center gap-2"
class:cursor-pointer={!quote?.group}
on:click={quote?.group ? null : options.setView("groups")}>
<i class="fa fa-circle-nodes" />
{#if opts.groups.length === 1}
{displayGroup(groups.key(opts.groups[0]).get())}
{:else if opts.groups.length > 1}
{quantify(opts.groups.length, "group")}
{/if}
</div>
{/if}
</div>
<div
class="mt-4 rounded-xl border border-solid border-gray-6 p-3"
class:bg-input={!showPreview}
@ -138,10 +172,6 @@
<small class="hidden sm:block">
{wordCount} words
</small>
<span class="hidden sm:block"></span>
<small>
Posting as @{displayPubkey($session.pubkey)}
</small>
<span></span>
<small on:click={togglePreview} class="cursor-pointer underline">
{showPreview ? "Hide" : "Show"} Preview
@ -164,53 +194,20 @@
</div>
<small
class="flex cursor-pointer items-center justify-end gap-4"
on:click={() => {
showSettings = true
}}>
<span><i class="fa fa-server" /> {$relays.length}</span>
<span><i class="fa fa-warning" /> {warning || 0}</span>
on:click={() => options.setView("settings")}>
{#if opts.anonymous}
<span><i class="fa fa-user-secret" /></span>
<span></span>
{/if}
<span><i class="fa fa-server" /> {opts.relays?.length}</span>
<span><i class="fa fa-warning" /> {opts.warning || 0}</span>
</small>
</div>
</Content>
</form>
{#if showSettings}
<Modal onEscape={closeSettings}>
<form on:submit|preventDefault={closeSettings}>
<Content>
<div class="mb-4 flex items-center justify-center">
<Heading>Note settings</Heading>
</div>
<Field icon="fa-warning" label="Content warnings">
<Input bind:value={warning} placeholder="Why might people want to skip this post?" />
</Field>
<div>Select which relays to publish to:</div>
<div>
{#each $relays as url}
<div
class="mb-2 mr-1 inline-block rounded-full border border-solid border-gray-1 px-2 py-1">
<button
type="button"
class="fa fa-times cursor-pointer"
on:click={() => removeRelay(url)} />
{displayRelay({url})}
</div>
{/each}
</div>
<RelaySearch bind:q limit={3} hideIfEmpty>
<div slot="item" let:relay>
<RelayCard {relay}>
<button
slot="actions"
class="underline"
on:click|preventDefault={() => saveRelay(relay.url)}>
Add relay
</button>
</RelayCard>
</div>
</RelaySearch>
<Anchor tag="button" theme="button" type="submit" class="w-full text-center">Done</Anchor>
</Content>
</form>
</Modal>
{/if}
<NoteOptions
bind:this={options}
on:change={setOpts}
initialValues={opts}
groupOptions={$groupOptions} />

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {pluck, reject, propEq} from "ramda"
import {pluck} from "ramda"
import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -23,7 +23,7 @@
}
const removeRelay = relay => {
relays = reject(propEq("url", relay.url), relays)
relays = relays.filter(r => r.url !== relay.url)
}
$: joined = new Set(pluck("url", relays))

View File

@ -98,9 +98,9 @@
{#if $mutes.has(pubkey)}
<Content size="lg" class="text-center">You have muted this person.</Content>
{:else if activeTab === "notes"}
<Feed {filter} />
<Feed showGroup {filter} />
{:else if activeTab === "likes"}
<Feed hideControls filter={{kinds: [7], authors: [pubkey]}} />
<Feed showGroup hideControls filter={{kinds: [7], authors: [pubkey]}} />
{:else if activeTab === "relays"}
{#if ownRelays.length > 0}
<PersonRelays relays={ownRelays} />

View File

@ -1,27 +1,31 @@
<script lang="ts">
import {uniqBy, uniq, sortBy, prop} from "ramda"
import {nip19} from "nostr-tools"
import {copyToClipboard} from "src/util/html"
import Input from "src/partials/Input.svelte"
import {createMap} from "hurdak"
import CopyValue from "src/partials/CopyValue.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Toggle from "src/partials/Toggle.svelte"
import Heading from "src/partials/Heading.svelte"
import {session} from "src/engine"
import {toast} from "src/partials/state"
import GroupCircle from "src/app/shared/GroupCircle.svelte"
import GroupName from "src/app/shared/GroupName.svelte"
import {session, groupSharedKeys, deriveGroupAccess, groupAdminKeys} from "src/engine"
const nip07 = "https://github.com/nostr-protocol/nips/blob/master/07.md"
const keypairUrl = "https://www.cloudflare.com/learning/ssl/how-does-public-key-encryption-work/"
let asHex = false
$: adminKeys = createMap("group", $groupAdminKeys)
$: sharedKeys = createMap(
"group",
uniqBy(
prop("group"),
sortBy(
k => -k.created_at,
$groupSharedKeys.filter(k => deriveGroupAccess(k.group).get())
)
)
)
$: pubkeyDisplay = asHex ? $session?.pubkey : nip19.npubEncode($session.pubkey)
$: privkeyDisplay =
asHex || !$session?.privkey ? $session.privkey : nip19.nsecEncode($session.privkey)
const copyKey = (type, value) => {
copyToClipboard(value)
toast.show("info", `Your ${type} key has been copied to the clipboard.`)
}
$: addresses = uniq([...Object.keys(adminKeys), ...Object.keys(sharedKeys)])
document.title = "Keys"
</script>
@ -36,61 +40,67 @@
>. This allows you to fully own your account, and move to another app if needed.
</p>
</div>
<div class="flex w-full flex-col gap-8">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<strong>Show keys in hex format</strong>
<Toggle bind:value={asHex} />
</div>
<p class="text-sm text-gray-1">
Under the hood, Nostr uses a different encoding to represent keys.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Public Key</strong>
<Input disabled value={pubkeyDisplay}>
<button
slot="after"
class="fa-solid fa-copy cursor-pointer"
on:click={() => copyKey("public", pubkeyDisplay)} />
</Input>
<p class="text-sm text-gray-1">
Your public key identifies your account. You can share this with people trying to find you
on nostr.
</p>
<div>
<CopyValue label="Public Key" value={$session?.pubkey} encode={nip19.npubEncode} />
<small class="text-gray-2">
Your public key identifies your account. You can share this with people trying to find you on
nostr.
</small>
</div>
{#if $session?.privkey}
<div class="flex flex-col gap-1">
<strong>Private Key</strong>
<Input disabled type="password" value={privkeyDisplay}>
<button
slot="after"
class="fa-solid fa-copy cursor-pointer"
on:click={() => copyKey("private", privkeyDisplay)} />
</Input>
<p class="text-sm text-gray-1">
<div>
<CopyValue
isPassword
label="Private Key"
value={$session?.privkey}
encode={nip19.nsecEncode} />
<small class="text-gray-2">
Your private key is used to prove your identity by cryptographically signing messages. <strong
>Do not share this with anyone.</strong>
Be careful about copying this into other apps - instead, consider using a <Anchor
href={nip07}
external>compatible browser extension</Anchor> to securely store your key.
</p>
</small>
</div>
{/if}
{#if $session?.bunkerKey}
<div class="flex flex-col gap-1">
<strong>Bunker Key</strong>
<Input disabled type="password" value={$session.bunkerKey}>
<button
slot="after"
class="fa-solid fa-copy cursor-pointer"
on:click={() => copyKey("bunker", $session.bunkerKey)} />
</Input>
<p class="text-sm text-gray-1">
<div>
<CopyValue
isPassword
label="Bunker Key"
value={$session?.bunkerKey}
encode={nip19.nsecEncode} />
<small class="text-gray-2">
Your bunker key is used to authorize Coracle with your nsec bunker to sign events on your
behalf. Save this if you would like to log in elsewhere without re-authorizing.
</p>
</small>
</div>
{/if}
{#if addresses.length > 0}
<div class="flex flex-col items-center justify-center">
<Heading>Group Keys</Heading>
<p>
These keys are used for accessing or managing closed groups. Save these to make sure you
don't lose access to your groups.
</p>
</div>
{#each addresses as address (address)}
{@const sharedKey = sharedKeys[address]}
{@const adminKey = adminKeys[address]}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<GroupCircle class="h-4 w-4" {address} />
<GroupName class="font-bold" {address} />
</div>
<div class="ml-6 flex flex-col gap-4">
{#if sharedKey}
<CopyValue isPassword label="Access key" value={sharedKey.privkey} />
{/if}
{#if adminKey}
<CopyValue isPassword label="Admin key" value={adminKey.privkey} />
{/if}
</div>
</div>
{/each}
{/if}
</Content>

View File

@ -4,7 +4,7 @@ import {createMapOf} from "hurdak"
import {now} from "paravel"
import {appDataKeys} from "src/util/nostr"
import {EventKind} from "src/engine/events/model"
import {Publisher, publishEvent, mention} from "src/engine/network/utils"
import {Publisher, createAndPublish, mention} from "src/engine/network/utils"
import {getInboxHints, getPubkeyHints} from "src/engine/relays/utils"
import {user, nip04, nip59} from "src/engine/session/derived"
import {setAppData} from "src/engine/session/commands"
@ -13,7 +13,7 @@ import {channels} from "./state"
export const publishNip04Message = async (recipient, content, tags = [], relays = null) => {
const pubkeys = [recipient, user.get().pubkey]
return publishEvent(EventKind.Nip04Message, {
return createAndPublish(EventKind.Nip04Message, {
relays: relays || getInboxHints(pubkeys),
content: await nip04.get().encryptAsUser(content, recipient),
tags: [...tags, ["p", recipient]],

View File

@ -1,4 +1,4 @@
import {prop, none, any, filter, whereEq} from "ramda"
import {none, any, filter, whereEq} from "ramda"
import {derived} from "src/engine/core/utils"
import {pubkey} from "src/engine/session/state"
import {mutes} from "src/engine/people/derived"
@ -29,7 +29,9 @@ export const nip04Channels = userChannels.derived(filter(whereEq({type: "nip04"}
export const unreadNip04Channels = nip04Channels.derived(filter(hasNewMessages))
export const hasNewNip04Messages = unreadNip04Channels.derived(any(prop("last_sent")))
export const hasNewNip04Messages = unreadNip04Channels.derived(
any((c: Channel) => Boolean(c.last_sent))
)
// Nip24
@ -37,4 +39,6 @@ export const nip24Channels = userChannels.derived(filter(whereEq({type: "nip24"}
export const unreadNip24Channels = nip24Channels.derived(filter(hasNewMessages))
export const hasNewNip24Messages = unreadNip24Channels.derived(any(prop("last_sent")))
export const hasNewNip24Messages = unreadNip24Channels.derived(
any((c: Channel) => Boolean(c.last_sent))
)

View File

@ -1,7 +1,7 @@
export const updateRecord = (record, timestamp, updates) => {
for (const [field, value] of Object.entries(updates)) {
const tsField = `${field}_updated_at`
const lastUpdated = record?.[tsField] || 0
const lastUpdated = record?.[tsField] || -1
if (timestamp > lastUpdated) {
record = {

View File

@ -216,7 +216,7 @@ export class Storage {
ready = defer()
dead = writable(false)
constructor(readonly adapters: (LocalStorageAdapter | IndexedDBAdapter)[]) {
constructor(readonly version, readonly adapters: (LocalStorageAdapter | IndexedDBAdapter)[]) {
this.initialize()
}
@ -242,7 +242,7 @@ export class Storage {
if (window.indexedDB) {
const dbConfig = indexedDBAdapters.map(adapter => adapter.getIndexedDBConfig())
this.db = new IndexedDB("nostr-engine/Storage", 5, dbConfig)
this.db = new IndexedDB("nostr-engine/Storage", this.version, dbConfig)
window.addEventListener("beforeunload", () => this.close())

View File

@ -196,6 +196,14 @@ export class Key<T extends R> implements Readable<T> {
return m
})
pop = () => {
const v = this.get()
this.remove()
return v
}
}
export class DerivedKey<T extends R> implements Readable<T> {

View File

@ -1,10 +1,11 @@
import {whereEq, find} from "ramda"
import {whereEq, groupBy, find} from "ramda"
import {Tags} from "paravel"
import {derived, DerivedCollection} from "src/engine/core/utils"
import {pubkey} from "src/engine/session/state"
import {settings} from "src/engine/session/derived"
import {getWotScore} from "src/engine/people/utils"
import {mutes, follows} from "src/engine/people/derived"
import {deriveGroupAccess} from "src/engine/groups/utils"
import type {Event} from "./model"
import {deletes, _events} from "./state"
@ -16,6 +17,8 @@ export const userEvents = new DerivedCollection<Event>("id", [events, pubkey], (
return $pk ? $e.filter(whereEq({pubkey: $pk})) : []
})
export const eventsByKind = events.derived(groupBy((e: Event) => String(e.kind)))
export const isEventMuted = derived([mutes, settings, pubkey], ([$mutes, $settings, $pubkey]) => {
const words = $settings.muted_words
const minWot = $settings.min_wot_score
@ -28,8 +31,9 @@ export const isEventMuted = derived([mutes, settings, pubkey], ([$mutes, $settin
return false
}
const reply = Tags.from(e).getReply()
const root = Tags.from(e).getRoot()
const tags = Tags.from(e)
const reply = tags.getReply()
const root = tags.getRoot()
if (find(t => $mutes.has(t), [e.id, e.pubkey, reply, root])) {
return true
@ -43,7 +47,10 @@ export const isEventMuted = derived([mutes, settings, pubkey], ([$mutes, $settin
return false
}
if (!$follows.has(e.pubkey) && getWotScore($pubkey, e.pubkey) < minWot) {
const address = tags.getCommunity()
const wotAdjustment = address && deriveGroupAccess(address) ? 1 : 0
if (!$follows.has(e.pubkey) && getWotScore($pubkey, e.pubkey) < minWot - wotAdjustment) {
return true
}

View File

@ -10,7 +10,7 @@ import {_events, deletes, deletesLastUpdated} from "./state"
projections.addGlobalHandler(
batch(500, (chunk: Event[]) => {
const $sessions = sessions.get()
const userEvents = chunk.filter(e => $sessions[e.pubkey])
const userEvents = chunk.filter(e => $sessions[e.pubkey] && !e.wrap)
if (userEvents.length > 0) {
_events.update($events => $events.concat(userEvents))
@ -33,9 +33,7 @@ projections.addHandler(EventKind.Delete, e => {
projections.addHandler(EventKind.GiftWrap, e => {
const session = sessions.get()[Tags.from(e).getValue("p")]
if (session?.method !== "privkey") {
return
}
if (session?.privkey) {
nip59.get().withUnwrappedEvent(e, session.privkey, e => projections.push(e))
}
})

View File

@ -1,8 +1,8 @@
import type {AddressPointer} from "nostr-tools/lib/nip19"
import {nip19} from "nostr-tools"
import {sortBy} from "ramda"
import {fromNostrURI, Tags} from "paravel"
import {tryFunc, switcherFn} from "hurdak"
import {Naddr} from "src/util/nostr"
import {getEventHints} from "src/engine/relays/utils"
import type {Event} from "./model"
@ -14,7 +14,7 @@ export const getIds = (e: Event) => {
const ids = [e.id]
if (isReplaceable(e)) {
ids.push(Naddr.fromEvent(e).asTagValue())
ids.push(Naddr.fromEvent(e, getEventHints(e)).asTagValue())
}
return ids
@ -56,65 +56,3 @@ export const decodeEvent = entity => {
default: () => annotateEvent(entity),
})
}
export class Naddr {
constructor(readonly kind, readonly pubkey, readonly identifier, readonly relays) {
this.kind = parseInt(kind)
this.identifier = identifier || ""
}
static fromEvent = (e: Event) =>
new Naddr(e.kind, e.pubkey, Tags.from(e).getValue("d"), getEventHints(e))
static fromTagValue = (a, relays = []) => {
const [kind, pubkey, identifier] = a.split(":")
return new Naddr(kind, pubkey, identifier, relays)
}
static fromTag = tag => {
const [a, hint] = tag.slice(1)
const relays = hint ? [hint] : []
return this.fromTagValue(a, relays)
}
static decode = naddr => {
let type,
data = {}
try {
;({type, data} = nip19.decode(naddr) as {
type: "naddr"
data: AddressPointer
})
} catch (e) {
console.warn(`Invalid naddr ${naddr}`)
}
if (type !== "naddr") {
console.warn(`Invalid naddr ${naddr}`)
}
return new Naddr(data.kind, data.pubkey, data.identifier, data.relays)
}
asTagValue = () => [this.kind, this.pubkey, this.identifier].join(":")
asTag = (mark = null) => {
const tag = ["a", this.asTagValue(), this.relays[0] || ""]
if (mark) {
tag.push(mark)
}
return tag
}
asFilter = () => ({
kinds: [this.kind],
authors: [this.pubkey],
"#d": [this.identifier],
})
encode = () => nip19.naddrEncode(this)
}

View File

@ -0,0 +1,328 @@
import {generatePrivateKey, getPublicKey} from "nostr-tools"
import {now, createEvent} from "paravel"
import {without, prop} from "ramda"
import {updateIn, randomId, filterVals} from "hurdak"
import {Naddr} from "src/util/nostr"
import {updateRecord} from "src/engine/core/commands"
import {Publisher} from "src/engine/network/utils"
import {pubkey, sessions} from "src/engine/session/state"
import {nip59, signer, session} from "src/engine/session/derived"
import {displayPubkey} from "src/engine/people/utils"
import {publishCommunitiesList} from "src/engine/lists/commands"
import {
getPubkeyHints,
getUserRelayUrls,
getGroupHints,
getGroupRelayUrls,
mergeHints,
} from "src/engine/relays/utils"
import {groups, groupAdminKeys, groupSharedKeys} from "./state"
import {deriveGroupAccess, deriveAdminKeyForGroup, deriveSharedKeyForGroup} from "./utils"
// Key state management
export const initSharedKey = address => {
const privkey = generatePrivateKey()
const pubkey = getPublicKey(privkey)
const key = {
group: address,
pubkey: pubkey,
privkey: privkey,
created_at: now(),
members: [],
}
groupSharedKeys.key(pubkey).set(key)
return key
}
export const initGroup = (members, relays) => {
const id = randomId()
const privkey = generatePrivateKey()
const pubkey = getPublicKey(privkey)
const address = `34550:${pubkey}:${id}`
const sharedKey = initSharedKey(address)
const adminKey = {
group: address,
pubkey: pubkey,
privkey: privkey,
created_at: now(),
relays,
members,
}
groupAdminKeys.key(pubkey).set(adminKey)
groups.key(address).set({id, pubkey, address, relays})
return {id, address, adminKey, sharedKey}
}
// Utils for publishing
export const getGroupPublishRelays = (address, overrides = null) => {
if (overrides?.length > 0) {
return overrides
}
const canonical = getGroupRelayUrls(address)
if (canonical.length > 0) {
return canonical
}
return getGroupHints(address)
}
export const publishToGroupAdmin = async (address, template) => {
const group = groups.key(address).get()
const {pubkey} = Naddr.fromTagValue(address)
const relays = group?.relays || getUserRelayUrls("write")
const event = await nip59.get().wrap(template, {
wrap: {
author: generatePrivateKey(),
recipient: pubkey,
},
})
return Publisher.publish({event, relays})
}
export const publishAsGroupAdminPublicly = async (address, template, relays = null) => {
const adminKey = deriveAdminKeyForGroup(address).get()
const event = await signer.get().signWithKey(template, adminKey.privkey)
return Publisher.publish({event, relays: getGroupPublishRelays(address, relays)})
}
export const publishAsGroupAdminPrivately = async (address, template, relays = null) => {
const adminKey = deriveAdminKeyForGroup(address).get()
const sharedKey = deriveSharedKeyForGroup(address).get()
const event = await nip59.get().wrap(template, {
author: adminKey.privkey,
wrap: {
author: sharedKey.privkey,
recipient: sharedKey.pubkey,
},
})
return Publisher.publish({event, relays: getGroupPublishRelays(address, relays)})
}
export const publishToGroupsPublicly = async (
addresses,
template,
{relays = null, anonymous = false} = {}
) => {
for (const address of addresses) {
const {access} = groups.key(address).get()
if (access === "closed") {
throw new Error("Attempted to publish publicly to a closed group")
}
template.tags.push(["a", address])
}
const event = anonymous
? await signer.get().signWithKey(template, generatePrivateKey())
: await signer.get().signAsUser(template)
return Publisher.publish({
event,
relays: relays || mergeHints(addresses.map(getGroupPublishRelays)),
})
}
export const publishToGroupsPrivately = async (
addresses,
template,
{relays = null, anonymous = false} = {}
) => {
const pubs = []
for (const address of addresses) {
const thisTemplate = updateIn("tags", (tags: string[][]) => [...tags, ["a", address]], template)
const {access} = groups.key(address).get()
const sharedKey = deriveSharedKeyForGroup(address).get()
const userAccess = deriveGroupAccess(address).get()
if (access === "open") {
throw new Error("Attempted to publish privately to a group that does not allow it")
}
if (userAccess !== "granted") {
throw new Error("Attempted to publish privately to a group the user is not a member of")
}
const event = await nip59.get().wrap(thisTemplate, {
author: anonymous ? generatePrivateKey() : session.get().privkey,
wrap: {
author: sharedKey.privkey,
recipient: sharedKey.pubkey,
},
})
pubs.push(
Publisher.publish({event, relays: relays || mergeHints(addresses.map(getGroupPublishRelays))})
)
}
return pubs
}
export const publishToZeroOrMoreGroups = async (
addresses,
template,
{shouldWrap, relays, anonymous = false}
) => {
if (addresses.length === 0) {
const event = anonymous
? await signer.get().signWithKey(template, generatePrivateKey())
: await signer.get().signAsUser(template)
return [await Publisher.publish({relays, event})]
}
// Don't use relay overrides if sending to a closed group
if (shouldWrap) {
return publishToGroupsPrivately(addresses, template, {anonymous})
}
return [await publishToGroupsPublicly(addresses, template, {relays, anonymous})]
}
// Admin functions
export const publishKeyRotations = async (address, pubkeys, template) => {
const adminKey = deriveAdminKeyForGroup(address).get()
return await Promise.all(
pubkeys.map(async pubkey => {
const relays = getPubkeyHints(pubkey, "read")
const event = await nip59.get().wrap(template, {
author: adminKey.privkey,
wrap: {
author: generatePrivateKey(),
recipient: pubkey,
},
})
return Publisher.publish({event, relays})
})
)
}
export const publishGroupInvites = async (address, pubkeys, relays, gracePeriod = 0) => {
const template = createEvent(24, {
tags: [
["a", address],
["grace_period", String(gracePeriod)],
["privkey", deriveSharedKeyForGroup(address).get().privkey],
...relays.map(url => ["relay", url]),
],
})
return publishKeyRotations(address, pubkeys, template)
}
export const publishGroupEvictions = async (address, pubkeys, gracePeriod) => {
const template = createEvent(24, {
tags: [
["a", address],
["grace_period", String(gracePeriod)],
],
})
publishKeyRotations(address, pubkeys, template)
}
export const publishGroupMeta = async (address, meta) => {
const template = createEvent(34550, {
tags: [
["d", meta.id],
["name", meta.name],
["image", meta.image],
["description", meta.description],
["access", meta.access],
...meta.relays.map(url => ["relay", url]),
],
})
return meta.access === "closed"
? publishAsGroupAdminPrivately(address, template, meta.relays)
: publishAsGroupAdminPublicly(address, template, meta.relays)
}
// Member functions
export const modifyGroupStatus = (session, address, timestamp, updates) => {
session.groups = session.groups || {}
session.groups[address] = updateRecord(session.groups[address], timestamp, updates)
return session
}
export const setGroupStatus = (pubkey, address, timestamp, updates) =>
sessions.update($sessions => ({
...$sessions,
[pubkey]: modifyGroupStatus($sessions[pubkey], address, timestamp, updates),
}))
export const resetGroupAccess = address =>
setGroupStatus(pubkey.get(), address, now(), {access: null})
export const publishGroupEntryRequest = address => {
setGroupStatus(pubkey.get(), address, now(), {access: "requested"})
return publishToGroupAdmin(
address,
createEvent(25, {
content: `${displayPubkey(pubkey.get())} would like to join the group`,
tags: [["a", address]],
})
)
}
export const publishGroupExitRequest = address => {
setGroupStatus(pubkey.get(), address, now(), {access: null})
return publishToGroupAdmin(
address,
createEvent(26, {
content: `${displayPubkey(pubkey.get())} is leaving the group`,
tags: [["a", address]],
})
)
}
export const joinPublicGroup = address =>
publishCommunitiesList(
Object.keys(filterVals(prop("joined"), session.get().groups)).concat(address)
)
export const leavePublicGroup = address =>
publishCommunitiesList(
without([address], Object.keys(filterVals(prop("joined"), session.get().groups)))
)
export const joinGroup = address => {
const group = groups.key(address)
if (group.get()?.access === "open") {
joinPublicGroup(address)
} else {
publishGroupEntryRequest(address)
}
}
export const leaveGroup = address => {
const group = groups.key(address)
if (group.get().access === "open") {
leavePublicGroup(address)
} else {
publishGroupExitRequest(address)
}
}

View File

@ -0,0 +1,5 @@
export * from "./model"
export * from "./state"
export * from "./utils"
export * from "./commands"
export * from "./projections"

View File

@ -0,0 +1,28 @@
import type {Event} from "src/engine/events/model"
export type Group = {
id: string
pubkey: string
address: string
updated_at?: number
access?: "open" | "closed" | "hybrid"
relays?: string[]
name?: string
image?: string
description?: string
moderators?: string[]
}
export type GroupKey = {
group: string
pubkey: string
privkey: string
created_at: number
members: string[]
hints?: string[]
}
export type GroupRequest = Event & {
group: string
resolved: boolean
}

View File

@ -0,0 +1,146 @@
import {uniq, mergeRight, assoc} from "ramda"
import {Tags} from "paravel"
import {updateIn} from "hurdak"
import {getPublicKey} from "nostr-tools"
import {Naddr} from "src/util/nostr"
import {projections} from "src/engine/core/projections"
import type {Event} from "src/engine/events/model"
import {EventKind} from "src/engine/events/model"
import {_events} from "src/engine/events/state"
import {sessions} from "src/engine/session/state"
import {nip59} from "src/engine/session/derived"
import {groups, groupSharedKeys, groupRequests} from "./state"
import {deriveAdminKeyForGroup, getRecipientKey} from "./utils"
import {modifyGroupStatus, setGroupStatus} from "./commands"
// Key sharing
projections.addHandler(24, (e: Event) => {
const tags = Tags.from(e)
const privkey = tags.getValue("privkey")
const address = tags.getValue("a")
const recipient = Tags.from(e.wrap).getValue("p")
if (!address) {
return
}
if (privkey) {
const pubkey = getPublicKey(privkey)
groupSharedKeys.key(pubkey).update($key => ({
pubkey,
privkey,
group: address,
created_at: e.created_at,
hints: tags.type("relay").values().all(),
members: [],
...$key,
}))
}
setGroupStatus(recipient, address, e.created_at, {
access: privkey ? "granted" : "revoked",
})
})
// Group metadata
projections.addHandler(34550, (e: Event) => {
const tags = Tags.from(e)
const meta = tags.getDict()
const address = Naddr.fromEvent(e).asTagValue()
const group = groups.key(address)
if (group.get()?.updated_at > e.created_at) {
return
}
group.set({
address,
id: meta.d,
pubkey: e.pubkey,
updated_at: e.created_at,
access: meta.access || "open",
relays: tags.type("relay").values().all(),
name: meta.name,
image: meta.image,
description: meta.description,
moderators: tags.mark("moderator").values().all(),
})
})
// Public community membership
projections.addHandler(10004, (e: Event) => {
const addresses = Tags.from(e).type("a").values().all()
let $session = sessions.get()[e.pubkey]
if (!$session) {
return
}
for (const address of uniq(Object.keys($session.groups || {}).concat(addresses))) {
$session = modifyGroupStatus($session, address, e.created_at, {
joined: addresses.includes(address),
})
}
sessions.update(assoc(e.pubkey, $session))
})
// Membership access/exit requests
const handleGroupRequest = access => (e: Event) => {
const address = Tags.from(e).getValue("a")
const adminKey = deriveAdminKeyForGroup(address)
if (adminKey.get()) {
groupRequests.key(e.id).update(
mergeRight({
...e,
group: address,
resolved: false,
})
)
}
if (sessions.get()[e.pubkey]) {
setGroupStatus(e.pubkey, address, e.created_at, {access})
}
}
projections.addHandler(25, handleGroupRequest("requested"))
projections.addHandler(26, handleGroupRequest(null))
// All other events are messages sent to the group
projections.addGlobalHandler((e: Event) => {
if (!e.wrap) {
return
}
const sharedKey = groupSharedKeys.key(e.wrap.pubkey)
if (sharedKey.exists()) {
_events.key(e.id).set(e)
sharedKey.update(
updateIn("members", (members?: string[]) => uniq([...(members || []), e.pubkey]))
)
}
})
// Unwrap gift wraps using known keys
projections.addHandler(EventKind.GiftWrap, wrap => {
const sk = getRecipientKey(wrap)
if (sk) {
nip59.get().withUnwrappedEvent(wrap, sk, rumor => {
projections.push(rumor)
})
}
})

View File

@ -0,0 +1,7 @@
import {collection} from "src/engine/core/utils"
import type {Group, GroupKey, GroupRequest} from "./model"
export const groups = collection<Group>("address")
export const groupAdminKeys = collection<GroupKey>("pubkey")
export const groupSharedKeys = collection<GroupKey>("pubkey")
export const groupRequests = collection<GroupRequest>("id")

View File

@ -0,0 +1,85 @@
import {prop, sortBy, last, whereEq} from "ramda"
import {ellipsize} from "hurdak"
import {Tags} from "paravel"
import {Naddr} from "src/util/nostr"
import {derived} from "src/engine/core/utils"
import {pubkey} from "src/engine/session/state"
import {session} from "src/engine/session/derived"
import {getUserRelayUrls, mergeHints} from "src/engine/relays/utils"
import {groups, groupSharedKeys, groupAdminKeys} from "./state"
import type {Group} from "./model"
export const getGroupNaddr = (group: Group) =>
Naddr.fromTagValue(group.address, group.relays).encode()
export const getGroupId = (group: Group) => group.address.split(":").slice(2).join(":")
export const getGroupName = (group: Group) => group.name || group.id
export const displayGroup = (group: Group) => ellipsize(group ? getGroupName(group) : "No name", 60)
export const getRecipientKey = wrap => {
const pubkey = Tags.from(wrap).pubkeys().first()
const sharedKey = groupSharedKeys.key(pubkey).get()
if (sharedKey) {
return sharedKey.privkey
}
const adminKey = groupAdminKeys.key(pubkey).get()
if (adminKey) {
return adminKey.privkey
}
return null
}
export const getGroupReqInfo = (address = null) => {
let $groupSharedKeys = groupSharedKeys.get()
let $groupAdminKeys = groupAdminKeys.get()
if (address) {
$groupSharedKeys = $groupSharedKeys.filter(whereEq({group: address}))
$groupAdminKeys = $groupAdminKeys.filter(whereEq({group: address}))
}
const admins = []
const recipients = [pubkey.get()]
const relaysByGroup = []
for (const key of [...$groupSharedKeys, ...$groupAdminKeys]) {
admins.push(Naddr.fromTagValue(key.group).pubkey)
recipients.push(key.pubkey)
const group = groups.key(key.group).get()
if (group?.relays) {
relaysByGroup[group.address] = group.relays
}
}
const relays = mergeHints([getUserRelayUrls("read"), ...Object.values(relaysByGroup)])
return {admins, recipients, relays}
}
export const deriveSharedKeyForGroup = (address: string) =>
groupSharedKeys.derived($keys =>
last(sortBy(prop("created_at"), $keys.filter(whereEq({group: address}))))
)
export const deriveAdminKeyForGroup = (address: string) => groupAdminKeys.key(address.split(":")[1])
export const deriveGroupAccess = address => {
return derived([groups.key(address), session], ([$group, $session]) => {
const status = $session?.groups?.[address] || {}
if ($group?.access === "open") {
return status.joined ? "granted" : null
} else {
return status.access
}
})
}

View File

@ -3,6 +3,7 @@ import {Storage, LocalStorageAdapter, IndexedDBAdapter, sortByPubkeyWhitelist} f
import {_lists} from "./lists"
import {people} from "./people"
import {relays} from "./relays"
import {groups, groupSharedKeys, groupAdminKeys, groupRequests} from "./groups"
import {_labels} from "./labels"
import {topics} from "./topics"
import {deletes, _events, deletesLastUpdated} from "./events"
@ -12,6 +13,7 @@ import {channels} from "./channels"
export * from "./core"
export * from "./channels"
export * from "./events"
export * from "./groups"
export * from "./labels"
export * from "./lists"
export * from "./media"
@ -25,7 +27,7 @@ export * from "./session"
export * from "./topics"
export * from "./zaps"
export const storage = new Storage([
export const storage = new Storage(6, [
new LocalStorageAdapter("pubkey", pubkey),
new LocalStorageAdapter("sessions", sessions),
new LocalStorageAdapter("deletes2", deletes, {
@ -34,10 +36,14 @@ export const storage = new Storage([
}),
new LocalStorageAdapter("deletesLastUpdated2", deletesLastUpdated),
new IndexedDBAdapter("events", _events, 10000, sortByPubkeyWhitelist(prop("created_at"))),
new IndexedDBAdapter("labels", _labels, 10000, sortBy(prop("created_at"))),
new IndexedDBAdapter("topics", topics, 10000, sortBy(prop("last_seen"))),
new IndexedDBAdapter("lists", _lists, 10000, sortByPubkeyWhitelist(prop("created_at"))),
new IndexedDBAdapter("people", people, 10000, sortByPubkeyWhitelist(prop("last_fetched"))),
new IndexedDBAdapter("relays", relays, 10000, sortBy(prop("count"))),
new IndexedDBAdapter("channels", channels, 10000, sortBy(prop("last_checked"))),
new IndexedDBAdapter("labels", _labels, 1000, sortBy(prop("created_at"))),
new IndexedDBAdapter("topics", topics, 1000, sortBy(prop("last_seen"))),
new IndexedDBAdapter("lists", _lists, 1000, sortByPubkeyWhitelist(prop("created_at"))),
new IndexedDBAdapter("people", people, 5000, sortByPubkeyWhitelist(prop("last_fetched"))),
new IndexedDBAdapter("relays", relays, 1000, sortBy(prop("count"))),
new IndexedDBAdapter("channels", channels, 1000, sortBy(prop("last_checked"))),
new IndexedDBAdapter("groups", groups, 1000, sortBy(prop("count"))),
new IndexedDBAdapter("groupRequests", groupRequests, 1000, sortBy(prop("created_at"))),
new IndexedDBAdapter("groupSharedKeys", groupSharedKeys, 1000, sortBy(prop("created_at"))),
new IndexedDBAdapter("groupAdminKeys", groupAdminKeys, 1000),
])

View File

@ -1,6 +1,6 @@
import {publishEvent} from "src/engine/network/utils"
import {createAndPublish} from "src/engine/network/utils"
export const publishReview = (content, tags, relays = null) =>
publishEvent(1986, {content, tags, relays})
createAndPublish(1986, {content, tags, relays})
export const publishLabel = (tags, relays = null) => publishEvent(1985, {tags, relays})
export const publishLabel = (tags, relays = null) => createAndPublish(1985, {tags, relays})

View File

@ -1,10 +1,13 @@
import {pubkey} from "src/engine/session/state"
import {publishEvent} from "src/engine/network/utils"
import {createAndPublish} from "src/engine/network/utils"
import {publishDeletion} from "src/engine/notes/commands"
export const publishBookmarksList = (id, name, tags) => {
publishEvent(30003, {tags: [["d", id], ["name", name], ...tags]})
createAndPublish(30003, {tags: [["d", id], ["name", name], ...tags]})
// migrate away from kind 30001
publishDeletion([`30001:${pubkey.get()}:${name}`])
}
export const publishCommunitiesList = addresses =>
createAndPublish(10004, {tags: addresses.map(a => ["a", a])})

View File

@ -1,8 +1,8 @@
import {whereEq, sortBy} from "ramda"
import {Naddr} from "src/util/nostr"
import {derivedCollection} from "src/engine/core/utils"
import {pubkey} from "src/engine/session/state"
import {deletes} from "src/engine/events/state"
import {Naddr} from "src/engine/events/utils"
import type {List} from "./model"
import {_lists} from "./state"

View File

@ -3,7 +3,7 @@ import {seconds} from "hurdak"
import {generatePrivateKey} from "nostr-tools"
import {getUserRelayUrls} from "src/engine/relays/utils"
import type {Event} from "src/engine/events/model"
import {publishEvent} from "./publish"
import {createAndPublish} from "./publish"
import {subscribe} from "./subscribe"
export type DVMRequestOpts = {
@ -31,7 +31,7 @@ export const dvmRequest = async ({
input = JSON.stringify(input)
}
publishEvent(kind, {
createAndPublish(kind, {
relays,
sk: generatePrivateKey(),
tags: tags.concat([

View File

@ -2,7 +2,6 @@ import {
reject,
partition,
find,
propEq,
uniqBy,
identity,
pluck,
@ -15,7 +14,7 @@ import {
import {ensurePlural, doPipe, batch} from "hurdak"
import {now, Tags} from "paravel"
import {race, pushToKey} from "src/util/misc"
import {noteKinds, reactionKinds, LOCAL_RELAY_URL} from "src/util/nostr"
import {noteKinds, reactionKinds} from "src/util/nostr"
import type {DisplayEvent} from "src/engine/notes/model"
import type {Event} from "src/engine/events/model"
import {isEventMuted} from "src/engine/events/derived"
@ -113,7 +112,7 @@ export class FeedLoader {
.filter(identity)
load({
relays: this.opts.relays.concat(LOCAL_RELAY_URL),
relays: this.opts.relays,
filters: getIdFilters(parentIds),
onEvent: batch(100, events => {
for (const e of this.discardEvents(events)) {
@ -170,7 +169,7 @@ export class FeedLoader {
break
}
if (noteKinds.includes(e.kind) && !find(propEq("id", e.id), parent.replies || [])) {
if (noteKinds.includes(e.kind) && !find(r => r.id === e.id, parent.replies || [])) {
pushToKey(parent as any, "replies", e)
}

View File

@ -1,10 +1,10 @@
import {omit, find, prop, groupBy, uniq} from "ramda"
import {shuffle, randomId, seconds, avg} from "hurdak"
import {Tags} from "paravel"
import {Naddr} from "src/util/nostr"
import {env, pubkey} from "src/engine/session/state"
import {follows, network} from "src/engine/people/derived"
import {mergeHints, getPubkeyHints} from "src/engine/relays/utils"
import {Naddr} from "src/engine/events/utils"
import type {DynamicFilter, Filter} from "../model"
export const calculateFilterGroup = ({since, until, limit, search, ...filter}: Filter) => {
@ -87,7 +87,7 @@ export const getReplyFilters = (events, filter) => {
}
export const getFilterGenerality = filter => {
if (filter.ids) {
if (filter.ids || filter["#e"] || filter["#a"]) {
return 0
}

View File

@ -4,13 +4,14 @@ import {omit, uniqBy, nth} from "ramda"
import {defer, union, difference} from "hurdak"
import {info} from "src/util/logger"
import {parseContent} from "src/util/notes"
import {Naddr} from "src/util/nostr"
import type {Event, NostrEvent} from "src/engine/events/model"
import {people} from "src/engine/people/state"
import {displayPerson} from "src/engine/people/utils"
import {getUserRelayUrls, getEventHint, getPubkeyHint} from "src/engine/relays/utils"
import {getUserRelayUrls, getEventHints, getEventHint, getPubkeyHint} from "src/engine/relays/utils"
import {signer} from "src/engine/session/derived"
import {projections} from "src/engine/core/projections"
import {Naddr, isReplaceable} from "src/engine/events/utils"
import {isReplaceable} from "src/engine/events/utils"
import {getUrls, getExecutor} from "./executor"
export type PublisherOpts = {
@ -128,15 +129,7 @@ export type PublishOpts = EventOpts & {
relays?: string[]
}
export const publishEvent = async (
kind: number,
{relays, content = "", tags = [], sk}: PublishOpts
) => {
const template = createEvent(kind, {
content,
tags: uniqTags([...tags, ...tagsFromContent(content)]),
})
export const publish = async (template, {sk, relays}: PublishOpts) => {
return Publisher.publish({
timeout: 5000,
relays: relays || getUserRelayUrls("write"),
@ -146,6 +139,18 @@ export const publishEvent = async (
})
}
export const createAndPublish = async (
kind: number,
{relays, sk, content = "", tags = []}: PublishOpts
) => {
const template = createEvent(kind, {
content,
tags: uniqTags([...tags, ...tagsFromContent(content)]),
})
return publish(template, {sk, relays})
}
export const uniqTags = uniqBy((t: string[]) =>
t[0] === "param" ? t.join(":") : t.slice(0, 2).join(":")
)
@ -192,12 +197,12 @@ export const getReplyTags = (parent: Event, inherit = false) => {
const extra = inherit
? tags
.type(["a", "e"])
.map(t => t.slice(0, 3).concat('mention'))
.map(t => t.slice(0, 3).concat("mention"))
.all()
: []
if (isReplaceable(parent)) {
extra.push(Naddr.fromEvent(parent).asTag("reply"))
extra.push(Naddr.fromEvent(parent, getEventHints(parent)).asTag("reply"))
}
return uniqBy(nth(1), [mention(parent.pubkey), root, ...extra, reply])

View File

@ -2,6 +2,7 @@ import type {SubscriptionOpts} from "paravel"
import {Subscription, now} from "paravel"
import {assoc, map} from "ramda"
import {updateIn} from "hurdak"
import {LOCAL_RELAY_URL} from 'src/util/nostr'
import type {Event} from "src/engine/events/model"
import {projections} from "src/engine/core/projections"
import {getUrls, getExecutor} from "./executor"
@ -21,7 +22,7 @@ export const subscribe = (opts: SubscribeOpts) => {
...opts,
hasSeen: tracker.add,
closeOnEose: Boolean(opts.timeout),
executor: getExecutor(getUrls(opts.relays)),
executor: getExecutor(getUrls(opts.relays.concat(LOCAL_RELAY_URL))),
})
sub.on("event", e => {

View File

@ -1,7 +1,8 @@
import {uniq, prop} from "ramda"
import {sleep} from "hurdak"
import {Emitter, hasValidSignature, matchFilters} from "paravel"
import {LOCAL_RELAY_URL} from "src/util/nostr"
import {events} from "src/engine/events/derived"
import {events, eventsByKind} from "src/engine/events/derived"
export class LocalTarget extends Emitter {
constructor() {
@ -34,7 +35,19 @@ export class LocalTarget extends Emitter {
tryEvent(events.key(id).get())
}
} else {
for (const event of events.get()) {
let $events
// Optimization: only iterate over events with the kinds we want
if (filters.every(prop("kinds"))) {
const kinds = uniq(filters.flatMap(prop("kinds")))
const $eventsByKind = eventsByKind.get()
$events = kinds.flatMap(k => $eventsByKind[k] || [])
} else {
$events = events.get()
}
for (const event of $events) {
tryEvent(event)
}
}

View File

@ -1,7 +1,6 @@
import {uniqBy, identity, prop, sortBy} from "ramda"
import {batch} from "hurdak"
import {Tags} from "paravel"
import {LOCAL_RELAY_URL} from "src/util/nostr"
import type {DisplayEvent} from "src/engine/notes/model"
import type {Event} from "src/engine/events/model"
import {writable} from "src/engine/core/utils"
@ -34,7 +33,7 @@ export class ThreadLoader {
if (filteredIds.length > 0) {
load({
relays: selectHints(this.relays).concat(LOCAL_RELAY_URL),
relays: selectHints(this.relays),
filters: getIdFilters(filteredIds),
onEvent: batch(300, (events: Event[]) => {
this.addToThread(events)

View File

@ -1,30 +1,18 @@
import {asNostrEvent} from "src/util/nostr"
import {getPublishHints, getUserRelayUrls} from "src/engine/relays/utils"
import {Publisher, publishEvent, getReplyTags} from "src/engine/network/utils"
import {createEvent} from "paravel"
import {getUserRelayUrls} from "src/engine/relays/utils"
import {createAndPublish, getReplyTags} from "src/engine/network/utils"
export const publishNote = (content, tags = [], relays = null) =>
publishEvent(1, {content, tags, relays})
export const publishReply = (parent, content, tags = []) => {
const relays = getPublishHints(parent)
// Re-broadcast the note we're replying to
Publisher.publish({relays, event: asNostrEvent(parent)})
return publishEvent(1, {relays, content, tags: [...tags, ...getReplyTags(parent, true)]})
}
createAndPublish(1, {content, tags, relays})
export const publishDeletion = (ids, relays = null) =>
publishEvent(5, {
createAndPublish(5, {
relays: relays || getUserRelayUrls("write"),
tags: ids.map(id => [id.includes(":") ? "a" : "e", id]),
})
export const publishReaction = (parent, content = "", tags = []) => {
const relays = getPublishHints(parent)
export const buildReply = (parent, content, tags = []) =>
createEvent(1, {content, tags: [...tags, ...getReplyTags(parent, true)]})
// Re-broadcast the note we're reacting to
Publisher.publish({relays, event: asNostrEvent(parent)})
return publishEvent(7, {relays, content, tags: [...tags, ...getReplyTags(parent)]})
}
export const buildReaction = (parent, content = "", tags = []) =>
createEvent(7, {content, tags: [...tags, ...getReplyTags(parent)]})

View File

@ -2,16 +2,16 @@ import {reject} from "ramda"
import {now} from "paravel"
import {stateKey, user, canSign} from "src/engine/session/derived"
import {updateStore} from "src/engine/core/commands"
import {publishEvent, mention} from "src/engine/network/utils"
import {createAndPublish, mention} from "src/engine/network/utils"
import {people} from "./state"
export const publishProfile = profile => publishEvent(0, {content: JSON.stringify(profile)})
export const publishProfile = profile => createAndPublish(0, {content: JSON.stringify(profile)})
export const publishPetnames = ($petnames: string[][]) => {
updateStore(people.key(stateKey.get()), now(), {petnames: $petnames})
if (canSign.get()) {
return publishEvent(3, {tags: $petnames})
return createAndPublish(3, {tags: $petnames})
}
}
@ -31,7 +31,7 @@ export const publishMutes = ($mutes: string[][]) => {
updateStore(people.key(stateKey.get()), now(), {mutes: $mutes})
if (canSign.get()) {
return publishEvent(10000, {tags: $mutes.map(t => t.slice(0, 2))})
return createAndPublish(10000, {tags: $mutes.map(t => t.slice(0, 2))})
}
}

View File

@ -3,7 +3,7 @@ import {now, normalizeRelayUrl, isShareableRelay} from "paravel"
import {people} from "src/engine/people/state"
import {canSign, stateKey} from "src/engine/session/derived"
import {updateStore} from "src/engine/core/commands"
import {publishEvent} from "src/engine/network/utils"
import {createAndPublish} from "src/engine/network/utils"
import type {RelayPolicy} from "./model"
import {relays} from "./state"
import {relayPolicies} from "./derived"
@ -40,7 +40,7 @@ export const publishRelays = ($relays: RelayPolicy[]) => {
updateStore(people.key(stateKey.get()), now(), {relays: $relays})
if (canSign.get()) {
return publishEvent(10002, {
return createAndPublish(10002, {
tags: $relays
.filter(r => isShareableRelay(r.url))
.map(r => {

View File

@ -1,13 +1,14 @@
import {nip19} from "nostr-tools"
import {Tags, isShareableRelay, normalizeRelayUrl as normalize, fromNostrURI} from "paravel"
import {sortBy, pluck, uniq, nth, prop, last} from "ramda"
import {sortBy, whereEq, pluck, uniq, nth, prop, last} from "ramda"
import {chain, displayList, first} from "hurdak"
import {fuzzy} from "src/util/misc"
import {LOCAL_RELAY_URL} from "src/util/nostr"
import {LOCAL_RELAY_URL, Naddr} from "src/util/nostr"
import type {Event} from "src/engine/events/model"
import {env} from "src/engine/session/state"
import {stateKey} from "src/engine/session/derived"
import {people} from "src/engine/people/state"
import {groups, groupSharedKeys} from "src/engine/groups/state"
import {pool} from "src/engine/network/state"
import {getSetting} from "src/engine/session/utils"
import type {Relay} from "./model"
@ -69,6 +70,23 @@ export const getUserRelays = (mode: string = null) => getPubkeyRelays(stateKey.g
export const getUserRelayUrls = (mode: string = null) => pluck("url", getUserRelays(mode))
export const getGroupRelayUrls = address => {
const group = groups.key(address).get()
const keys = groupSharedKeys.get()
if (group?.relays) {
return group.relays
}
const latestKey = last(sortBy(prop("created_at"), keys.filter(whereEq({group: address}))))
if (latestKey) {
return latestKey.hints
}
return []
}
// Smart relay selection
//
// From Mike Dilger:
@ -190,6 +208,11 @@ export const getInboxHints = hintSelector(function* (pubkeys: string[]) {
yield* mergeHints(pubkeys.map(pk => getPubkeyHints(pk, "read")))
})
export const getGroupHints = hintSelector(function* (address: string) {
yield* getGroupRelayUrls(address)
yield* getPubkeyHints(Naddr.fromTagValue(address).pubkey)
})
export const mergeHints = (groups: string[][], limit: number = null) => {
const scores = {} as Record<string, any>

View File

@ -1,7 +1,7 @@
import {omit, assoc} from "ramda"
import {generatePrivateKey, getPublicKey} from "nostr-tools"
import {appDataKeys} from "src/util/nostr"
import {publishEvent} from "src/engine/network/utils"
import {createAndPublish} from "src/engine/network/utils"
import type {Session} from "./model"
import {sessions, pubkey} from "./state"
import {canSign, nip04, session} from "./derived"
@ -40,7 +40,7 @@ export const setAppData = async (d: string, data: any) => {
const json = JSON.stringify(data)
const content = await nip04.get().encryptAsUser(json, pubkey)
return publishEvent(30078, {content, tags: [["d", d]]})
return createAndPublish(30078, {content, tags: [["d", d]]})
}
}

View File

@ -1,3 +1,10 @@
export type GroupStatus = {
joined: boolean
joined_updated_at: number
access: "requested" | "granted" | "revoked"
access_updated_at: number
}
export type Session = {
method: string
pubkey: string
@ -9,4 +16,5 @@ export type Session = {
notifications_last_synced?: number
nip04_messages_last_synced?: number
nip24_messages_last_synced?: number
groups?: Record<string, GroupStatus>
}

View File

@ -91,8 +91,13 @@ export class Nip59 {
if (!wrap.content.includes("ciphertext")) {
try {
const seal = await this.decrypt(wrap, sk)
if (!seal) throw new Error("Failed to decrypt wrapper")
const rumor = await this.decrypt(seal, sk)
if (!rumor) throw new Error("Failed to decrypt seal")
if (seal.pubkey === rumor.pubkey) {
return Object.assign(rumor, {wrap, seen_on: wrap.seen_on})
}

View File

@ -74,7 +74,6 @@ export const getLnUrl = (address: string) => {
}
export const fetchZapper = createBatcher(3000, async (lnurls: string[]) => {
const keys = ["callback", "minSendable", "maxSendable", "nostrPubkey", "allowsNostr"]
const data =
(await tryFunc(async () => {
// Dufflepud expects plaintext but we store lnurls encoded
@ -94,7 +93,10 @@ export const fetchZapper = createBatcher(3000, async (lnurls: string[]) => {
return null
}
return {...pick(keys, zapper), lnurl} as Zapper
return {
...pick(["callback", "minSendable", "maxSendable", "nostrPubkey", "allowsNostr"], zapper),
lnurl,
} as Zapper
})
})

View File

@ -6,6 +6,7 @@
export let stopPropagation = false
export let external = false
export let disabled = false
export let loading = false
export let modal = false
export let theme = "unstyled"
@ -21,11 +22,8 @@
$: className = cx(
$$props.class,
"transition-all",
{
"opacity-50": loading,
"cursor-pointer": !loading,
},
"transition-all cursor-pointer",
{"opacity-50 pointer-events-none": loading || disabled},
switcher(theme, {
anchor: "underline",
button:

View File

@ -5,9 +5,10 @@
import Toggle from "src/partials/Toggle.svelte"
import {router} from "src/app/router"
export let label
export let value
export let label
export let encode = null
export let isPassword = false
let showEncoded = true
@ -15,7 +16,7 @@
const copy = () => {
copyToClipboard(displayValue)
toast.show("info", `${label} copied to clipboard!`)
toast.show("info", `${label || "Contents"} copied to clipboard!`)
}
const share = () => router.at("qrcode").at(displayValue).open()
@ -23,7 +24,11 @@
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<strong>{label}</strong>
<strong class="flex-grow">
<slot name="label">
{label}
</slot>
</strong>
{#if encode}
<Popover triggerType="mouseenter">
<div slot="trigger">
@ -36,10 +41,12 @@
<div class="flex min-w-0 gap-4 font-mono text-sm">
<div class="flex gap-4 p-1">
<i class="fa-solid fa-copy cursor-pointer" on:click={copy} />
{#if !isPassword}
<i class="fa-solid fa-qrcode cursor-pointer" on:click={share} />
{/if}
</div>
<p class="min-w-0 overflow-hidden text-ellipsis">
{displayValue}
{isPassword ? displayValue.replace(/./g, "•") : displayValue}
</p>
</div>
</div>

View File

@ -6,7 +6,7 @@
<div class="flex flex-col gap-1">
<slot name="label">
<div class="flex justify-between">
<label class="font-bold">
<label class="flex items-center gap-2 font-bold">
{#if icon}
<i class={`fa ${icon}`} />
{/if}

View File

@ -51,7 +51,7 @@ export class AudioController extends EventEmitter {
this.audio.pause()
this.emit("pause")
clearInterval(this.interval)
clearInterval(this.interval as unknown as number)
this.interval = null
}
@ -66,7 +66,7 @@ export class AudioController extends EventEmitter {
}
cleanup() {
clearInterval(this.interval)
clearInterval(this.interval as unknown as number)
this.hls?.destroy()
this.audio.pause()

View File

@ -7,7 +7,7 @@ import type {Filter, Event} from "src/engine"
export const noteKinds = [1, 30023, 1063, 9802, 1808, 32123]
export const personKinds = [0, 2, 3, 10000, 10002]
export const reactionKinds = [7, 9735]
export const userKinds = [...personKinds, 30001, 30003, 30078]
export const userKinds = [...personKinds, 30001, 30003, 30078, 10004]
export const LOCAL_RELAY_URL = "local://coracle.relay"
@ -21,6 +21,7 @@ export const isLike = (content: string) =>
["", "+", "🤙", "👍", "❤️", "😎", "🏅", "🫂", "🤣", "😂", "💜"].includes(content)
export const channelAttrs = ["name", "about", "picture"]
export const groupAttrs = ["name", "about", "picture"]
export const asNostrEvent = e =>
pick(["content", "created_at", "id", "kind", "pubkey", "sig", "tags"], e) as Event
@ -53,14 +54,80 @@ export const getAvgRating = (events: Event[]) => avg(events.map(getRating).filte
export const isHex = x => x.match(/^[a-f0-9]{64}$/)
export const getIdOrNaddr = e => {
export const getIdOrAddress = e => {
if (between(9999, 20000, e.kind) || between(39999, 40000, e.kind)) {
return `${e.kind}:${e.pubkey}:${Tags.from(e).getValue("d")}`
return Naddr.fromEvent(e).asTagValue()
}
return e.id
}
export const getGroupAddress = e =>
Tags.from(e)
.type("a")
.values()
.find(a => a.startsWith("34550:"))
export class Naddr {
constructor(readonly kind, readonly pubkey, readonly identifier, readonly relays) {
this.kind = parseInt(kind)
this.identifier = identifier || ""
}
static fromEvent = (e: Event, relays = []) =>
new Naddr(e.kind, e.pubkey, Tags.from(e).getValue("d"), relays)
static fromTagValue = (a, relays = []) => {
const [kind, pubkey, identifier] = a.split(":")
return new Naddr(kind, pubkey, identifier, relays)
}
static fromTag = (tag, relays = []) => {
const [a, hint] = tag.slice(1)
return this.fromTagValue(a, relays.concat(hint ? [hint] : []))
}
static decode = naddr => {
let type,
data = {}
try {
;({type, data} = nip19.decode(naddr) as {
type: "naddr"
data: AddressPointer
})
} catch (e) {}
if (type !== "naddr") {
console.warn(`Invalid naddr ${naddr}`)
}
return new Naddr(data.kind, data.pubkey, data.identifier, data.relays)
}
asTagValue = () => [this.kind, this.pubkey, this.identifier].join(":")
asTag = (mark = null) => {
const tag = ["a", this.asTagValue(), this.relays[0] || ""]
if (mark) {
tag.push(mark)
}
return tag
}
asFilter = () => ({
kinds: [this.kind],
authors: [this.pubkey],
"#d": [this.identifier],
})
encode = () => nip19.naddrEncode(this)
}
const WARN_TAGS = new Set([
"nsfw",
"nude",

View File

@ -189,6 +189,7 @@ export class Router {
page = this.pages.derived(first)
modals = this.nonVirtual.derived(takeWhile((h: HistoryItem) => h.config.modal))
modal = this.modals.derived(first)
current = this.nonVirtual.derived(first)
init() {
this.at(window.location.pathname + window.location.search).push()
@ -289,6 +290,6 @@ export class Router {
}
fromCurrent() {
return this.from(first(this.nonVirtual.get()))
return this.from(this.current.get())
}
}

BIN
yarn.lock

Binary file not shown.