Add mutes

This commit is contained in:
Jonathan Staab 2023-03-09 11:04:36 -06:00
parent bfc011551a
commit c4c72abddc
14 changed files with 184 additions and 187 deletions

View File

@ -4,6 +4,8 @@
- [x] Make "show new notes" button fixed position
- [x] Gray out buttons that don't work when logged in with pubkey
- [x] Clean up popovers, re-design notes on small screens
- [x] Migrate muffle to mute, add thread muting
## 0.2.16

View File

@ -1,5 +1,8 @@
# Current
- [ ] Check mention interpolation indexes nevent1qqsx27cspgfcj93kryt2zpzzt5ua60rtucckvcmsrqc949e6t83jaxspzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxg46e8sv
- [ ] Muffle -> mute
- Add mute button on profile
- [ ] Show loading/success on zap invoice screen
- [ ] Fix iOS/safari/firefox
- [ ] Update https://nostr.com/clients/coracle

View File

@ -60,7 +60,6 @@
import NoteDetail from "src/views/notes/NoteDetail.svelte"
import PersonList from "src/views/person/PersonList.svelte"
import PersonProfileInfo from "src/views/person/PersonProfileInfo.svelte"
import PersonSettings from "src/views/person/PersonSettings.svelte"
import PersonShare from "src/views/person/PersonShare.svelte"
import AddRelay from "src/views/relays/AddRelay.svelte"
import RelayCard from "src/views/relays/RelayCard.svelte"
@ -250,8 +249,6 @@
<PubKeyLogin />
{:else if $modal.type === "login/connect"}
<ConnectUser />
{:else if $modal.type === "person/settings"}
<PersonSettings person={$modal.person} />
{:else if $modal.type === "person/info"}
<PersonProfileInfo person={$modal.person} />
{:else if $modal.type === "person/share"}

View File

@ -26,8 +26,8 @@ const setRelays = newRelays =>
const setPetnames = petnames =>
new PublishableEvent(3, {tags: petnames})
const muffle = muffle =>
new PublishableEvent(12165, {tags: muffle})
const setMutes = mutes =>
new PublishableEvent(10000, {tags: mutes})
const createRoom = room =>
new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))})
@ -132,7 +132,7 @@ class PublishableEvent {
}
export default {
updateUser, setRelays, setPetnames, muffle, createRoom, updateRoom,
updateUser, setRelays, setPetnames, setMutes, createRoom, updateRoom,
createChatMessage, createDirectMessage, createNote, createReaction,
createReply, requestZap, deleteEvent, PublishableEvent,
}

View File

@ -96,7 +96,23 @@ const processProfileEvents = async events => {
return data
},
12165: () => ({muffle: e.tags}),
10000: () => {
if (e.created_at > (person.mutes_updated_at || 0)) {
return {
mutes_updated_at: e.created_at,
mutes: e.tags,
}
}
},
// DEPRECATED
12165: () => {
if (e.created_at > (person.mutes_updated_at || 0)) {
return {
mutes_updated_at: e.created_at,
mutes: e.tags,
}
}
},
// DEPRECATED
10001: () => {
if (e.created_at > (person.relays_updated_at || 0)) {

View File

@ -1,8 +1,8 @@
import type {Person} from 'src/util/types'
import type {Readable} from 'svelte/store'
import {last, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda'
import {slice, identity, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda'
import {findReplyId, findRootId} from 'src/util/nostr'
import {synced} from 'src/util/misc'
import {Tags} from 'src/util/nostr'
import {derived} from 'svelte/store'
import database from 'src/agent/database'
import keys from 'src/agent/keys'
@ -19,9 +19,11 @@ let settingsCopy = null
let profileCopy = null
let petnamesCopy = []
let relaysCopy = []
let mutesCopy = []
const anonPetnames = synced('agent/user/anonPetnames', [])
const anonRelays = synced('agent/user/anonRelays', [])
const anonMutes = synced('agent/user/anonMutes', [])
const settings = synced("agent/user/settings", {
relayLimit: 20,
@ -42,17 +44,17 @@ const profile = derived(
}
) as Readable<Person>
const petnames = derived(
[profile, anonPetnames],
([$profile, $anonPetnames]) =>
$profile?.petnames || $anonPetnames
const profileKeyWithDefault = (key, stores) => derived(
[profile, ...stores],
([$profile, ...values]) =>
$profile?.[key] || find(identity, values)
)
const relays = derived(
[profile, anonRelays],
([$profile, $anonRelays]) =>
$profile?.relays || $anonRelays
)
const petnames = profileKeyWithDefault('petnames', [anonPetnames])
const relays = profileKeyWithDefault('relays', [anonRelays])
// Backwards compat, migrate muffle to mute temporarily
const mutes = profileKeyWithDefault('mutes', [anonMutes, derived(profile, prop('muffle'))])
const canPublish = derived(
[keys.pubkey, relays],
@ -74,6 +76,10 @@ petnames.subscribe($petnames => {
petnamesCopy = $petnames
})
mutes.subscribe($mutes => {
mutesCopy = $mutes
})
relays.subscribe($relays => {
relaysCopy = $relays
})
@ -92,34 +98,6 @@ const user = {
canPublish,
getProfile: () => profileCopy,
getPubkey: () => profileCopy?.pubkey,
muffle: events => {
const muffle = user.getMuffle()
return events.filter(e => !muffle.has(e.pubkey))
},
getMuffle: () => {
return new Set(
Tags
.wrap((profileCopy?.muffle || []))
.filter(t => Math.random() > parseFloat(last(t)))
.values()
.all()
)
},
mute: events => {
const mutes = user.getMutes()
return events.filter(e => !mutes.has(e.pubkey))
},
getMutes: () => {
return new Set(
Tags
.wrap((profileCopy?.muffle || []))
.filter(t => parseFloat(last(t)) === 0)
.values()
.all()
)
},
// Petnames
@ -166,6 +144,39 @@ const user = {
setRelayWriteCondition(url, write) {
return this.updateRelays(map(when(whereEq({url}), assoc('write', write))))
},
// Mutes
mutes,
getMutes: () => mutesCopy,
applyMutes: events => {
const m = new Set(mutesCopy.map(m => m[1]))
return events.filter(e =>
!(m.has(e.id) || m.has(e.pubkey) || m.has(findReplyId(e)) || m.has(findRootId(e)))
)
},
updateMutes(f) {
const $mutes = f(mutesCopy)
console.log(mutesCopy, $mutes)
anonMutes.set($mutes)
if (profileCopy) {
return cmd.setMutes($mutes.map(slice(0, 2))).publish(relaysCopy)
}
},
addMute(type, value) {
return this.updateMutes(
pipe(
reject(t => t[1] === value),
concat([[type, value]])
)
)
},
removeMute(pubkey) {
return this.updateMutes(reject(t => t[1] === pubkey))
},
}
export default user

View File

@ -1,5 +1,5 @@
<script>
import {sortBy, assoc} from "ramda"
import {sortBy, any, assoc} from "ramda"
import {onMount} from "svelte"
import {fly} from "svelte/transition"
import {now, createScroller} from "src/util/misc"
@ -22,13 +22,11 @@
return createScroller(async () => {
limit += 10
// Filter out alerts for which we failed to find the required context. The bug
// Filter out mutes, and alerts for which we failed to find the required context. The bug
// is really upstream of this, but it's an easy fix
const events = user
.mute(database.alerts.all())
.filter(
e => e.replies.length > 0 || e.likedBy.length > 0 || e.zappedBy?.length > 0 || e.isMention
)
.applyMutes(database.alerts.all())
.filter(e => any(k => e[k].length > 0, ["replies", "likedBy", "zappedBy"]) || e.isMention)
notes = sortBy(e => -e.created_at, events).slice(0, limit)
})

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {last} from "ramda"
import {last, find} from "ramda"
import {onMount} from "svelte"
import {tweened} from "svelte/motion"
import {fly, fade} from "svelte/transition"
@ -26,11 +26,12 @@
export let relays = []
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const {petnamePubkeys, canPublish} = user
const {petnamePubkeys, canPublish, mutes} = user
const getRelays = () => sampleRelays(relays.concat(getPubkeyWriteRelays(pubkey)))
let pubkey = toHex(npub)
let following = false
let muted = false
let followers = new Set()
let followersCount = tweened(0, {interpolate, duration: 1000})
let person = database.getPersonWithFallback(pubkey)
@ -39,6 +40,7 @@
let actions = []
$: following = $petnamePubkeys.includes(pubkey)
$: muted = find(m => m[1] === pubkey, $mutes)
$: {
actions = []
@ -53,12 +55,17 @@
actions.push({onClick: follow, label: "Follow", icon: "user-plus"})
}
if (muted) {
actions.push({onClick: unmute, label: "Muted", icon: "microphone-slash"})
} else if (user.getPubkey() !== pubkey) {
actions.push({onClick: mute, label: "Mute", icon: "microphone"})
}
actions.push({
onClick: () => navigate(`/messages/${npub}`),
label: "Message",
icon: "envelope",
})
actions.push({onClick: openAdvanced, label: "Advanced", icon: "sliders"})
}
actions.push({onClick: openProfileInfo, label: "Profile", icon: "info"})
@ -133,8 +140,12 @@
user.removePetname(pubkey)
}
const openAdvanced = () => {
modal.set({type: "person/settings", person})
const mute = async () => {
user.addMute("p", pubkey)
}
const unmute = async () => {
user.removeMute(pubkey)
}
const openProfileInfo = () => {

View File

@ -3,7 +3,7 @@ import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} fr
import {nip19} from 'nostr-tools'
import {ensurePlural, ellipsize, first} from 'hurdak/lib/hurdak'
export const personKinds = [0, 2, 3, 10001, 10002, 12165]
export const personKinds = [0, 2, 3, 10000, 10001, 10002, 12165]
export class Tags {
tags: Array<any>

View File

@ -9,9 +9,9 @@ export type Relay = {
export type Person = {
pubkey: string
relays?: Array<Relay>
muffle?: Array<Array<string>>
petnames?: Array<Array<string>>
relays?: Array<Relay>
mutes?: Array<Array<string>>
kind0?: {
name?: string
about?: string

View File

@ -37,7 +37,7 @@
const onChunk = async newNotes => {
// Deduplicate and filter out stuff we don't want, apply user preferences
const filtered = user.muffle(newNotes.filter(n => !seen.has(n.id) && shouldDisplay(n)))
const filtered = user.applyMutes(newNotes.filter(n => !seen.has(n.id) && shouldDisplay(n)))
// Drop the oldest 20% of notes. We sometimes get pretty old stuff since we don't
// use a since on our filter
@ -68,7 +68,7 @@
depth: 2,
notes: combined,
onChunk: context => {
context = user.muffle(context)
context = user.applyMutes(context)
notesBuffer = network.applyContext(notesBuffer, context)
notes = network.applyContext(notes, context)
@ -118,10 +118,11 @@
<Content size="inherit" class="pt-6">
{#if notesBuffer.length > 0}
<div class="fixed left-0 top-0 z-10 mt-20 flex w-full justify-center">
<div class="pointer-events-none fixed left-0 top-0 z-10 mt-20 flex w-full justify-center">
<button
in:fly={{y: 20}}
class="cursor-pointer rounded-full border border-solid border-accentl bg-accent py-2 px-4 text-center shadow-lg transition-colors hover:bg-accentl"
class="pointer-events-auto cursor-pointer rounded-full border border-solid border-accentl
bg-accent py-2 px-4 text-center shadow-lg transition-colors hover:bg-accentl"
on:click={loadBufferedNotes}>
Load {quantify(notesBuffer.length, "new note")}
</button>

View File

@ -5,7 +5,6 @@
import {onMount} from "svelte"
import {tweened} from "svelte/motion"
import {slide} from "svelte/transition"
import {navigate} from "svelte-routing"
import {quantify} from "hurdak/lib/hurdak"
import {Tags, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
import {formatTimestamp, now, tryJson, formatSats, fetchJson} from "src/util/misc"
@ -54,7 +53,7 @@
let visibleNotes = []
let showRelays = false
const {profile, canPublish} = user
const {profile, canPublish, mutes} = user
const timestamp = formatTimestamp(note.created_at)
const borderColor = invertColors ? "medium" : "dark"
const links = extractUrls(note.content)
@ -63,6 +62,7 @@
const person = database.watch("people", () => database.getPersonWithFallback(note.pubkey))
let likes, flags, zaps, like, flag, border, childrenContainer, noteContainer, canZap
let muted = false
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const likesCount = tweened(0, {interpolate})
@ -70,6 +70,7 @@
const zapsTotal = tweened(0, {interpolate})
const repliesCount = tweened(0, {interpolate})
$: muted = find(m => m[1] === note.id, $mutes)
$: likes = note.reactions.filter(n => isLike(n.content))
$: flags = note.reactions.filter(whereEq({content: "-"}))
$: zaps = note.zaps
@ -143,11 +144,15 @@
modal.set({type: "note/detail", note: {id: findRootId(note)}, relays})
}
const react = async content => {
if (!$profile) {
return navigate("/login")
}
const mute = async () => {
user.addMute("e", note.id)
}
const unmute = async () => {
user.removeMute(note.id)
}
const react = async content => {
const relays = getEventPublishRelays(note)
const [event] = await cmd.createReaction(note, content).publish(relays)
@ -173,11 +178,7 @@
}
const startReply = () => {
if ($profile) {
reply = reply || true
} else {
navigate("/login")
}
reply = reply || true
}
const removeMention = pubkey => {
@ -385,6 +386,10 @@
You have flagged this content as offensive.
<Anchor on:click={() => deleteReaction(flag)}>Unflag</Anchor>
</p>
{:else if muted}
<p class="border-l-2 border-solid border-medium pl-4 text-light">
You have muted this note.
</p>
{:else}
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<p>{@html renderNote(note, {showEntire})}</p>
@ -394,60 +399,69 @@
</button>
{/if}
</div>
<div class="flex justify-between text-light">
<div
class={cx("flex", {
"pointer-events-none opacity-75": !$canPublish,
})}>
<button class="w-16 text-left" on:click|stopPropagation={startReply}>
<i class="fa fa-reply cursor-pointer" />
{$repliesCount}
</button>
<button
class="w-16 text-left"
class:text-accent={like}
on:click|stopPropagation={() => (like ? deleteReaction(like) : react("+"))}>
<i
class={cx("fa fa-heart cursor-pointer", {
"fa-beat fa-beat-custom": like,
})} />
{$likesCount}
</button>
<button
class={cx("w-20 text-left", {
"pointer-events-none opacity-50": !canZap,
})}
class:text-accent={zapped}
on:click|stopPropagation={startZap}>
<i class="fa fa-bolt cursor-pointer" />
{formatSats($zapsTotal)}
</button>
</div>
<div on:click|stopPropagation>
<Popover theme="transparent">
<div slot="trigger" class="cursor-pointer px-2">
<i class="fa fa-ellipsis-v" />
</div>
<div
slot="tooltip"
let:instance
class="flex flex-col gap-2"
on:click={() => instance.hide()}>
<Anchor
type="button-circle"
on:click={() => {
showRelays = true
}}>
<i class="fa fa-server" />
</Anchor>
<Anchor type="button-circle" on:click={() => react("-")}>
<i class="fa fa-flag" />
</Anchor>
</div>
</Popover>
</div>
</div>
{/if}
<div class="flex justify-between text-light">
<div
class={cx("flex", {
"pointer-events-none opacity-75": !$canPublish || flag || muted,
})}>
<button class="w-16 text-left" on:click|stopPropagation={startReply}>
<i class="fa fa-reply cursor-pointer" />
{$repliesCount}
</button>
<button
class="w-16 text-left"
class:text-accent={like}
on:click|stopPropagation={() => (like ? deleteReaction(like) : react("+"))}>
<i
class={cx("fa fa-heart cursor-pointer", {
"fa-beat fa-beat-custom": like,
})} />
{$likesCount}
</button>
<button
class={cx("w-20 text-left", {
"pointer-events-none opacity-50": !canZap,
})}
class:text-accent={zapped}
on:click|stopPropagation={startZap}>
<i class="fa fa-bolt cursor-pointer" />
{formatSats($zapsTotal)}
</button>
</div>
<div on:click|stopPropagation>
<Popover theme="transparent">
<div slot="trigger" class="cursor-pointer px-2">
<i class="fa fa-ellipsis-v" />
</div>
<div
slot="tooltip"
let:instance
class="flex flex-col gap-2"
on:click={() => instance.hide()}>
<Anchor
type="button-circle"
on:click={() => {
showRelays = true
}}>
<i class="fa fa-server" />
</Anchor>
{#if muted}
<Anchor type="button-circle" on:click={unmute}>
<i class="fa fa-microphone" />
</Anchor>
{:else}
<Anchor type="button-circle" on:click={mute}>
<i class="fa fa-microphone-slash" />
</Anchor>
{/if}
<Anchor type="button-circle" on:click={() => react("-")}>
<i class="fa fa-flag" />
</Anchor>
</div>
</Popover>
</div>
</div>
</div>
</div>
</Card>
@ -503,7 +517,7 @@
</div>
{/if}
{#if visibleNotes.length > 0 && depth > 0}
{#if visibleNotes.length > 0 && depth > 0 && !muted}
<div class="relative">
<div class={`absolute w-px bg-${borderColor} z-10 -mt-4 ml-4 h-0`} bind:this={border} />
<div class="note-children relative ml-8 flex flex-col gap-4" bind:this={childrenContainer}>

View File

@ -40,7 +40,7 @@
depth: 6,
notes: [note],
onChunk: context => {
note = first(network.applyContext([note], user.muffle(context)))
note = first(network.applyContext([note], user.applyMutes(context)))
},
})
}
@ -54,7 +54,7 @@
<Content size="lg" class="text-center">Sorry, we weren't able to find this note.</Content>
</div>
{:else if note.pubkey}
<div in:fly={{y: 20}} class="m-auto flex max-w-2xl flex-col gap-4 p-4">
<div in:fly={{y: 20}} class="m-auto flex w-full max-w-2xl flex-col gap-4 p-4">
<Note showContext depth={6} anchorId={note.id} note={asDisplayEvent(note)} {invertColors} />
</div>
{/if}

View File

@ -1,56 +0,0 @@
<script>
import {last} from "ramda"
import {switcher, first} from "hurdak/lib/hurdak"
import {fly} from "svelte/transition"
import Button from "src/partials/Button.svelte"
import Content from "src/partials/Content.svelte"
import SelectButton from "src/partials/SelectButton.svelte"
import user from "src/agent/user"
import {getUserWriteRelays} from "src/agent/relays"
import cmd from "src/agent/cmd"
import {publishWithToast} from "src/app"
export let person
const muffle = user.getProfile().muffle || []
const muffleOptions = ["Never", "Sometimes", "Often", "Always"]
const muffleValue = parseFloat(first(muffle.filter(t => t[1] === person.pubkey).map(last)) || 1)
const values = {
// Scale up to integers for each choice we have
muffle: switcher(Math.round(muffleValue * 3), muffleOptions),
}
const save = async e => {
e.preventDefault()
// Scale back down to a decimal based on string value
const muffleValue = muffleOptions.indexOf(values.muffle) / 3
const muffleTags = muffle
.filter(t => t[1] !== person.pubkey)
.concat([["p", person.pubkey, muffleValue.toString()]])
.filter(t => last(t) !== "1")
publishWithToast(getUserWriteRelays(), cmd.muffle(muffleTags))
history.back()
}
</script>
<form in:fly={{y: 20}} on:submit={save}>
<Content class="text-white">
<div class="flex flex-col gap-2">
<h1 class="text-3xl">Advanced Follow</h1>
<p>Fine grained controls for interacting with other people.</p>
</div>
<div class="flex flex-col gap-1">
<strong>How often do you want to see notes from this person?</strong>
<SelectButton bind:value={values.muffle} options={muffleOptions} />
<p class="text-sm text-light">
"Never" is effectively a mute, while "Always" will show posts whenever available. If you
want a middle ground, choose "Sometimes" or "Often".
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
</Content>
</form>