mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-29 00:10:52 +00:00
Build encrypted group management
This commit is contained in:
parent
c833caf77a
commit
6bae64459d
@ -3,6 +3,7 @@
|
||||
# 0.3.13
|
||||
|
||||
- [x] Update lists to use new 30003 user bookmarks kind
|
||||
- [x] Add anonymous posting
|
||||
|
||||
# 0.3.12
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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, {
|
||||
|
@ -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/)
|
||||
|
@ -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>
|
||||
|
@ -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(":")) {
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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 || "",
|
||||
|
40
src/app/shared/GroupAbout.svelte
Normal file
40
src/app/shared/GroupAbout.svelte
Normal 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>
|
93
src/app/shared/GroupActions.svelte
Normal file
93
src/app/shared/GroupActions.svelte
Normal 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>
|
15
src/app/shared/GroupCircle.svelte
Normal file
15
src/app/shared/GroupCircle.svelte
Normal 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}
|
107
src/app/shared/GroupDetailsForm.svelte
Normal file
107
src/app/shared/GroupDetailsForm.svelte
Normal 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>
|
32
src/app/shared/GroupMember.svelte
Normal file
32
src/app/shared/GroupMember.svelte
Normal 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>
|
9
src/app/shared/GroupName.svelte
Normal file
9
src/app/shared/GroupName.svelte
Normal 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>
|
67
src/app/shared/GroupRequest.svelte
Normal file
67
src/app/shared/GroupRequest.svelte
Normal 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>
|
23
src/app/shared/GroupSummary.svelte
Normal file
23
src/app/shared/GroupSummary.svelte
Normal 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>
|
@ -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;" />
|
||||
|
@ -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,15 +151,15 @@
|
||||
|
||||
if ($env.FORCE_RELAYS.length === 0) {
|
||||
actions.push({label: "Broadcast", icon: "rss", onClick: broadcast})
|
||||
|
||||
actions.push({
|
||||
label: "Details",
|
||||
icon: "info",
|
||||
onClick: () => {
|
||||
showDetails = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: "Details",
|
||||
icon: "info",
|
||||
onClick: () => {
|
||||
showDetails = true
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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") {
|
||||
|
194
src/app/shared/NoteOptions.svelte
Normal file
194
src/app/shared/NoteOptions.svelte
Normal 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}
|
@ -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,19 +81,31 @@
|
||||
|
||||
const send = async () => {
|
||||
const content = getContent()
|
||||
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
const tags = data.mentions.map(mention)
|
||||
|
||||
if (content) {
|
||||
const pub = await publishReply(parent, content, tags)
|
||||
|
||||
dispatch("event", pub.event)
|
||||
|
||||
pub.on("progress", toastProgress)
|
||||
|
||||
clearDraft()
|
||||
|
||||
reset()
|
||||
if (opts.warning) {
|
||||
tags.push(["content-warning", opts.warning])
|
||||
}
|
||||
|
||||
// Re-broadcast the note we're replying to
|
||||
if (!opts.shouldWrap) {
|
||||
Publisher.publish({relays: opts.relays, event: asNostrEvent(parent)})
|
||||
}
|
||||
|
||||
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 => {
|
||||
@ -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} />
|
||||
|
@ -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 = []
|
||||
|
@ -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"}
|
||||
<NoteDetail {...data} />
|
||||
{#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"}
|
||||
|
@ -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"
|
||||
|
||||
|
@ -59,7 +59,7 @@
|
||||
</Content>
|
||||
{/if}
|
||||
{#key key}
|
||||
<Feed {filter} {relays}>
|
||||
<Feed showGroup {filter} {relays}>
|
||||
<div slot="controls">
|
||||
{#if $canSign}
|
||||
{#if $userLists.length > 0}
|
||||
|
29
src/app/views/GroupCreate.svelte
Normal file
29
src/app/views/GroupCreate.svelte
Normal 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} />
|
133
src/app/views/GroupDetail.svelte
Normal file
133
src/app/views/GroupDetail.svelte
Normal 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>
|
32
src/app/views/GroupEdit.svelte
Normal file
32
src/app/views/GroupEdit.svelte
Normal 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} />
|
28
src/app/views/GroupInfo.svelte
Normal file
28
src/app/views/GroupInfo.svelte
Normal 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>
|
86
src/app/views/GroupList.svelte
Normal file
86
src/app/views/GroupList.svelte
Normal 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>
|
25
src/app/views/GroupListItem.svelte
Normal file
25
src/app/views/GroupListItem.svelte
Normal 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>
|
98
src/app/views/GroupRotate.svelte
Normal file
98
src/app/views/GroupRotate.svelte
Normal 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>
|
@ -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,
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
Publisher.publish({
|
||||
relays: $relays,
|
||||
event: asNostrEvent(quote),
|
||||
})
|
||||
if (!opts.groups.length) {
|
||||
Publisher.publish({
|
||||
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">
|
||||
<strong>What do you want to say?</strong>
|
||||
<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} />
|
||||
|
@ -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))
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
{#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">
|
||||
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>
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
<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.
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $session?.bunkerKey}
|
||||
<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.
|
||||
</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>
|
||||
|
@ -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]],
|
||||
|
@ -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))
|
||||
)
|
||||
|
@ -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 = {
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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> {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
nip59.get().withUnwrappedEvent(e, session.privkey, e => projections.push(e))
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
|
328
src/engine/groups/commands.ts
Normal file
328
src/engine/groups/commands.ts
Normal 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)
|
||||
}
|
||||
}
|
5
src/engine/groups/index.ts
Normal file
5
src/engine/groups/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./model"
|
||||
export * from "./state"
|
||||
export * from "./utils"
|
||||
export * from "./commands"
|
||||
export * from "./projections"
|
28
src/engine/groups/model.ts
Normal file
28
src/engine/groups/model.ts
Normal 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
|
||||
}
|
146
src/engine/groups/projections.ts
Normal file
146
src/engine/groups/projections.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
7
src/engine/groups/state.ts
Normal file
7
src/engine/groups/state.ts
Normal 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")
|
85
src/engine/groups/utils.ts
Normal file
85
src/engine/groups/utils.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
@ -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),
|
||||
])
|
||||
|
@ -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})
|
||||
|
@ -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])})
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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([
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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])
|
||||
|
@ -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 => {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)]})
|
||||
|
@ -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))})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 => {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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]]})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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})
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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} />
|
||||
<i class="fa-solid fa-qrcode cursor-pointer" on:click={share} />
|
||||
{#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>
|
||||
|
@ -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}
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user