diff --git a/src/Element/Text.css b/src/Element/Text.css
index 0b13975fd..8903a4297 100644
--- a/src/Element/Text.css
+++ b/src/Element/Text.css
@@ -69,3 +69,10 @@
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
+
+.text blockquote {
+ margin: 0;
+ color: var(--font-secondary-color);
+ border-left: 2px solid var(--font-secondary-color);
+ padding-left: 12px;
+}
diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx
index 89e04cbf9..0324309f9 100644
--- a/src/Element/Timeline.tsx
+++ b/src/Element/Timeline.tsx
@@ -1,44 +1,48 @@
import "./Timeline.css";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faForward } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useMemo } from "react";
+
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import LoadMore from "Element/LoadMore";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faForward } from "@fortawesome/free-solid-svg-icons";
+import useModeration from "Hooks/useModeration";
export interface TimelineProps {
postsOnly: boolean,
subject: TimelineSubject,
method: "TIME_RANGE" | "LIMIT_UNTIL"
+ ignoreModeration?: boolean
}
/**
* A list of notes by pubkeys
*/
-export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
+export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false }: TimelineProps) {
+ const { muted, isMuted } = useModeration();
const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
method
});
const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
- return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
- }, [postsOnly]);
+ return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey));
+ }, [postsOnly, muted]);
const mainFeed = useMemo(() => {
return filterPosts(main.notes);
}, [main, filterPosts]);
const latestFeed = useMemo(() => {
- return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
+ return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.TextNote: {
- return
+ return
}
case EventKind.Reaction:
case EventKind.Repost: {
diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts
index 125cf17b0..c4d44a363 100644
--- a/src/Feed/EventPublisher.ts
+++ b/src/Feed/EventPublisher.ts
@@ -1,10 +1,11 @@
import { useSelector } from "react-redux";
+
import { System } from "Nostr/System";
import { default as NEvent } from "Nostr/Event";
import EventKind from "Nostr/EventKind";
import Tag from "Nostr/Tag";
import { RootState } from "State/Store";
-import { HexKey, RawEvent, u256, UserMetadata } from "Nostr";
+import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
import { bech32ToHex } from "Util"
import { DefaultRelays, HashtagRegex } from "Const";
@@ -95,6 +96,28 @@ export default function useEventPublisher() {
}
}
},
+ muted: async (keys: HexKey[], priv: HexKey[]) => {
+ if (pubKey) {
+ let ev = NEvent.ForPubKey(pubKey);
+ ev.Kind = EventKind.Lists;
+ ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length))
+ keys.forEach(p => {
+ ev.Tags.push(new Tag(["p", p], ev.Tags.length))
+ })
+ let content = ""
+ if (priv.length > 0) {
+ const ps = priv.map(p => ["p", p])
+ const plaintext = JSON.stringify(ps)
+ if (hasNip07 && !privKey) {
+ content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext));
+ } else if (privKey) {
+ content = await ev.EncryptData(plaintext, pubKey, privKey)
+ }
+ }
+ ev.Content = content;
+ return await signEvent(ev);
+ }
+ },
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
@@ -279,7 +302,7 @@ const delay = (t: number) => {
});
}
-const barierNip07 = async (then: () => Promise
) => {
+export const barierNip07 = async (then: () => Promise) => {
while (isNip07Busy) {
await delay(10);
}
@@ -289,4 +312,4 @@ const barierNip07 = async (then: () => Promise) => {
} finally {
isNip07Busy = false;
}
-};
\ No newline at end of file
+};
diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts
index 7c4521f2e..cfc8a2a50 100644
--- a/src/Feed/LoginFeed.ts
+++ b/src/Feed/LoginFeed.ts
@@ -1,24 +1,27 @@
-import Nostrich from "nostrich.jpg";
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
-import { HexKey, TaggedRawEvent } from "Nostr";
+import { makeNotification } from "Notifications";
+import { TaggedRawEvent, HexKey, Lists } from "Nostr";
import EventKind from "Nostr/EventKind";
+import Event from "Nostr/Event";
import { Subscriptions } from "Nostr/Subscriptions";
-import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login";
-import { RootState } from "State/Store";
+import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
+import type { RootState } from "State/Store";
import { db } from "Db";
+import { barierNip07 } from "Feed/EventPublisher";
import useSubscription from "Feed/Subscription";
+import { getMutedKeys, getNewest } from "Feed/MuteList";
import { mapEventToProfile, MetadataCache } from "Db/User";
-import { getDisplayName } from "Element/ProfileImage";
-import { MentionRegex } from "Const";
+import useModeration from "Hooks/useModeration";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
- const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]);
+ const { publicKey: pubKey, privateKey: privKey } = useSelector((s: RootState) => s.login);
+ const { isMuted } = useModeration();
const subMetadata = useMemo(() => {
if (!pubKey) return null;
@@ -27,6 +30,7 @@ export default function useLoginFeed() {
sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
+ sub.Limit = 2
return sub;
}, [pubKey]);
@@ -42,6 +46,19 @@ export default function useLoginFeed() {
return sub;
}, [pubKey]);
+ const subMuted = useMemo(() => {
+ if (!pubKey) return null;
+
+ let sub = new Subscriptions();
+ sub.Id = "login:muted";
+ sub.Kinds = new Set([EventKind.Lists]);
+ sub.Authors = new Set([pubKey]);
+ sub.DTag = Lists.Muted;
+ sub.Limit = 1;
+
+ return sub;
+ }, [pubKey]);
+
const subDms = useMemo(() => {
if (!pubKey) return null;
@@ -61,6 +78,7 @@ export default function useLoginFeed() {
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true });
const notificationFeed = useSubscription(subNotification, { leaveOpen: true });
const dmsFeed = useSubscription(subDms, { leaveOpen: true });
+ const mutedFeed = useSubscription(subMuted, { leaveOpen: true });
useEffect(() => {
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
@@ -75,7 +93,7 @@ export default function useLoginFeed() {
dispatch(setRelays({ relays, createdAt: cl.created_at }));
}
let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]);
- dispatch(setFollows(pTags));
+ dispatch(setFollows({ keys: pTags, createdAt: cl.created_at }));
}
(async () => {
@@ -85,7 +103,7 @@ export default function useLoginFeed() {
acc.created = v.created;
}
return acc;
- }, { created: 0, profile: null });
+ }, { created: 0, profile: null as MetadataCache | null });
if (maxProfile.profile) {
let existing = await db.users.get(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) {
@@ -93,70 +111,52 @@ export default function useLoginFeed() {
}
}
})().catch(console.warn);
- }, [metadataFeed.store]);
+ }, [dispatch, metadataFeed.store]);
useEffect(() => {
- let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote);
-
- if ("Notification" in window && Notification.permission === "granted") {
- for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) {
- sendNotification(nx)
- .catch(console.warn);
+ const replies = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey))
+ replies.forEach(nx => {
+ makeNotification(nx).then(notification => {
+ if (notification) {
+ // @ts-ignore
+ dispatch(sendNotification(notification))
}
- }
+ })
+ })
+ }, [dispatch, notificationFeed.store]);
- dispatch(addNotifications(notifications));
- }, [notificationFeed.store]);
+ useEffect(() => {
+ const muted = getMutedKeys(mutedFeed.store.notes)
+ dispatch(setMuted(muted))
+
+ const newest = getNewest(mutedFeed.store.notes)
+ if (newest && newest.content.length > 0 && pubKey) {
+ decryptBlocked(newest, pubKey, privKey).then((plaintext) => {
+ try {
+ const blocked = JSON.parse(plaintext)
+ const keys = blocked.filter((p:any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1])
+ dispatch(setBlocked({
+ keys,
+ createdAt: newest.created_at,
+ }))
+ } catch(error) {
+ console.debug("Couldn't parse JSON")
+ }
+ }).catch((error) => console.warn(error))
+ }
+ }, [dispatch, mutedFeed.store])
useEffect(() => {
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms));
- }, [dmsFeed.store]);
+ }, [dispatch, dmsFeed.store]);
}
-async function makeNotification(ev: TaggedRawEvent) {
- switch (ev.kind) {
- case EventKind.TextNote: {
- const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
- const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
- const fromUser = users.find(a => a?.pubkey === ev.pubkey);
- const name = getDisplayName(fromUser, ev.pubkey);
- const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;
- return {
- title: `Reply from ${name}`,
- body: replaceTagsWithUser(ev, users).substring(0, 50),
- icon: avatarUrl
- }
- }
- }
- return null;
+async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
+ const ev = new Event(raw)
+ if (pubKey && privKey) {
+ return await ev.DecryptData(raw.content, privKey, pubKey)
+ } else {
+ return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content));
+ }
}
-
-function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
- return ev.content.split(MentionRegex).map(match => {
- let matchTag = match.match(/#\[(\d+)\]/);
- if (matchTag && matchTag.length === 2) {
- let idx = parseInt(matchTag[1]);
- let ref = ev.tags[idx];
- if (ref && ref[0] === "p" && ref.length > 1) {
- let u = users.find(a => a.pubkey === ref[1]);
- return `@${getDisplayName(u, ref[1])}`;
- }
- }
- return match;
- }).join();
-}
-
-async function sendNotification(ev: TaggedRawEvent) {
- let n = await makeNotification(ev);
- if (n != null && Notification.permission === "granted") {
- let worker = await navigator.serviceWorker.ready;
- worker.showNotification(n.title, {
- body: n.body,
- icon: n.icon,
- tag: "notification",
- timestamp: ev.created_at * 1000,
- vibrate: [500]
- });
- }
-}
\ No newline at end of file
diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts
new file mode 100644
index 000000000..79eb14d29
--- /dev/null
+++ b/src/Feed/MuteList.ts
@@ -0,0 +1,46 @@
+import { useMemo } from "react";
+
+import { HexKey, TaggedRawEvent, Lists } from "Nostr";
+import EventKind from "Nostr/EventKind";
+import { Subscriptions } from "Nostr/Subscriptions";
+import useSubscription, { NoteStore } from "Feed/Subscription";
+
+export default function useMutedFeed(pubkey: HexKey) {
+ const sub = useMemo(() => {
+ let sub = new Subscriptions();
+ sub.Id = `muted:${pubkey.slice(0, 12)}`;
+ sub.Kinds = new Set([EventKind.Lists]);
+ sub.Authors = new Set([pubkey]);
+ sub.DTag = Lists.Muted;
+ sub.Limit = 1;
+ return sub;
+ }, [pubkey]);
+
+ return useSubscription(sub);
+}
+
+export function getNewest(rawNotes: TaggedRawEvent[]){
+ const notes = [...rawNotes]
+ notes.sort((a, b) => a.created_at - b.created_at)
+ if (notes.length > 0) {
+ return notes[0]
+ }
+}
+
+export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
+ const newest = getNewest(rawNotes)
+ if (newest) {
+ const { created_at, tags } = newest
+ const keys = tags.filter(t => t[0] === "p").map(t => t[1])
+ return {
+ keys,
+ createdAt: created_at,
+ }
+ }
+ return { createdAt: 0, keys: [] }
+}
+
+export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] {
+ let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey);
+ return getMutedKeys(lists).keys;
+}
diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx
new file mode 100644
index 000000000..a09316add
--- /dev/null
+++ b/src/Hooks/useModeration.tsx
@@ -0,0 +1,78 @@
+import { useSelector, useDispatch } from "react-redux";
+
+import type { RootState } from "State/Store";
+import { HexKey } from "Nostr";
+import useEventPublisher from "Feed/EventPublisher";
+import { setMuted, setBlocked } from "State/Login";
+
+
+export default function useModeration() {
+ const dispatch = useDispatch()
+ const { blocked, muted } = useSelector((s: RootState) => s.login)
+ const publisher = useEventPublisher()
+
+ async function setMutedList(pub: HexKey[], priv: HexKey[]) {
+ try {
+ const ev = await publisher.muted(pub, priv)
+ console.debug(ev);
+ publisher.broadcast(ev)
+ } catch (error) {
+ console.debug("Couldn't change mute list")
+ }
+ }
+
+ function isMuted(id: HexKey) {
+ return muted.includes(id) || blocked.includes(id)
+ }
+
+ function isBlocked(id: HexKey) {
+ return blocked.includes(id)
+ }
+
+ function unmute(id: HexKey) {
+ const newMuted = muted.filter(p => p !== id)
+ dispatch(setMuted({
+ createdAt: new Date().getTime(),
+ keys: newMuted
+ }))
+ setMutedList(newMuted, blocked)
+ }
+
+ function unblock(id: HexKey) {
+ const newBlocked = blocked.filter(p => p !== id)
+ dispatch(setBlocked({
+ createdAt: new Date().getTime(),
+ keys: newBlocked
+ }))
+ setMutedList(muted, newBlocked)
+ }
+
+ function mute(id: HexKey) {
+ const newMuted = muted.includes(id) ? muted : muted.concat([id])
+ setMutedList(newMuted, blocked)
+ dispatch(setMuted({
+ createdAt: new Date().getTime(),
+ keys: newMuted
+ }))
+ }
+
+ function block(id: HexKey) {
+ const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id])
+ setMutedList(muted, newBlocked)
+ dispatch(setBlocked({
+ createdAt: new Date().getTime(),
+ keys: newBlocked
+ }))
+ }
+
+ function muteAll(ids: HexKey[]) {
+ const newMuted = Array.from(new Set(muted.concat(ids)))
+ setMutedList(newMuted, blocked)
+ dispatch(setMuted({
+ createdAt: new Date().getTime(),
+ keys: newMuted
+ }))
+ }
+
+ return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
+}
diff --git a/src/Nostr/Event.ts b/src/Nostr/Event.ts
index baf7aa3dd..b19eb27ab 100644
--- a/src/Nostr/Event.ts
+++ b/src/Nostr/Event.ts
@@ -139,26 +139,33 @@ export default class Event {
}
/**
- * Encrypt the message content in place
+ * Encrypt the given message content
*/
- async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
+ async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) {
let key = await this._GetDmSharedKey(pubkey, privkey);
let iv = window.crypto.getRandomValues(new Uint8Array(16));
- let data = new TextEncoder().encode(this.Content);
+ let data = new TextEncoder().encode(content);
let result = await window.crypto.subtle.encrypt({
name: "AES-CBC",
iv: iv
}, key, data);
let uData = new Uint8Array(result);
- this.Content = `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
+ return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`;
}
/**
- * Decrypt the content of this message in place
+ * Encrypt the message content in place
*/
- async DecryptDm(privkey: HexKey, pubkey: HexKey) {
+ async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) {
+ this.Content = await this.EncryptData(this.Content, pubkey, privkey);
+ }
+
+ /**
+ * Decrypt the content of the message
+ */
+ async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) {
let key = await this._GetDmSharedKey(pubkey, privkey);
- let cSplit = this.Content.split("?iv=");
+ let cSplit = cyphertext.split("?iv=");
let data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0);
@@ -169,7 +176,14 @@ export default class Event {
name: "AES-CBC",
iv: iv
}, key, data);
- this.Content = new TextDecoder().decode(result);
+ return new TextDecoder().decode(result);
+ }
+
+ /**
+ * Decrypt the content of this message in place
+ */
+ async DecryptDm(privkey: HexKey, pubkey: HexKey) {
+ this.Content = await this.DecryptData(this.Content, privkey, pubkey)
}
async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) {
@@ -177,4 +191,4 @@ export default class Event {
let sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
}
-}
\ No newline at end of file
+}
diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts
index d12012b98..b8a0de042 100644
--- a/src/Nostr/EventKind.ts
+++ b/src/Nostr/EventKind.ts
@@ -7,7 +7,8 @@ const enum EventKind {
DirectMessage = 4, // NIP-04
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
- Reaction = 7 // NIP-25
+ Reaction = 7, // NIP-25
+ Lists = 30000, // NIP-51
};
export default EventKind;
\ No newline at end of file
diff --git a/src/Nostr/Subscriptions.ts b/src/Nostr/Subscriptions.ts
index b9af235ae..bc6fe0682 100644
--- a/src/Nostr/Subscriptions.ts
+++ b/src/Nostr/Subscriptions.ts
@@ -42,6 +42,11 @@ export class Subscriptions {
*/
HashTags?: Set;
+ /**
+ * A "d" tag to search
+ */
+ DTag?: string;
+
/**
* a timestamp, events must be newer than this to pass
*/
@@ -89,6 +94,7 @@ export class Subscriptions {
this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined;
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
+ this.DTag = sub?.["#d"] ? sub["#d"] : undefined;
this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined;
this.Limit = sub?.limit ?? undefined;
@@ -130,9 +136,12 @@ export class Subscriptions {
if (this.PTags) {
ret["#p"] = Array.from(this.PTags);
}
- if(this.HashTags) {
+ if (this.HashTags) {
ret["#t"] = Array.from(this.HashTags);
}
+ if (this.DTag) {
+ ret["#d"] = this.DTag;
+ }
if (this.Since !== null) {
ret.since = this.Since;
}
@@ -144,4 +153,4 @@ export class Subscriptions {
}
return ret;
}
-}
\ No newline at end of file
+}
diff --git a/src/Nostr/Tag.ts b/src/Nostr/Tag.ts
index e10af8617..fb961ae91 100644
--- a/src/Nostr/Tag.ts
+++ b/src/Nostr/Tag.ts
@@ -8,6 +8,7 @@ export default class Tag {
Relay?: string;
Marker?: string;
Hashtag?: string;
+ DTag?: string;
Index: number;
Invalid: boolean;
@@ -36,6 +37,10 @@ export default class Tag {
}
break;
}
+ case "d": {
+ this.DTag = tag[1];
+ break;
+ }
case "t": {
this.Hashtag = tag[1];
break;
@@ -61,9 +66,12 @@ export default class Tag {
case "t": {
return ["t", this.Hashtag!];
}
+ case "d": {
+ return ["d", this.DTag!];
+ }
default: {
return this.Original;
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts
index cac6946e8..1d8c43847 100644
--- a/src/Nostr/index.ts
+++ b/src/Nostr/index.ts
@@ -35,6 +35,7 @@ export type RawReqFilter = {
"#e"?: u256[],
"#p"?: u256[],
"#t"?: string[],
+ "#d"?: string,
since?: number,
until?: number,
limit?: number
@@ -53,4 +54,11 @@ export type UserMetadata = {
nip05?: string,
lud06?: string,
lud16?: string
-}
\ No newline at end of file
+}
+
+/**
+ * NIP-51 list types
+ */
+export enum Lists {
+ Muted = "mute"
+}
diff --git a/src/Notifications.ts b/src/Notifications.ts
new file mode 100644
index 000000000..c92b60400
--- /dev/null
+++ b/src/Notifications.ts
@@ -0,0 +1,43 @@
+import Nostrich from "nostrich.jpg";
+
+import { TaggedRawEvent } from "Nostr";
+import EventKind from "Nostr/EventKind";
+import type { NotificationRequest } from "State/Login";
+import { db } from "Db";
+import { MetadataCache } from "Db/User";
+import { getDisplayName } from "Element/ProfileImage";
+import { MentionRegex } from "Const";
+
+export async function makeNotification(ev: TaggedRawEvent): Promise {
+ switch (ev.kind) {
+ case EventKind.TextNote: {
+ const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
+ const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!);
+ const fromUser = users.find(a => a?.pubkey === ev.pubkey);
+ const name = getDisplayName(fromUser, ev.pubkey);
+ const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;
+ return {
+ title: `Reply from ${name}`,
+ body: replaceTagsWithUser(ev, users).substring(0, 50),
+ icon: avatarUrl,
+ timestamp: ev.created_at * 1000,
+ }
+ }
+ }
+ return null;
+}
+
+function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) {
+ return ev.content.split(MentionRegex).map(match => {
+ let matchTag = match.match(/#\[(\d+)\]/);
+ if (matchTag && matchTag.length === 2) {
+ let idx = parseInt(matchTag[1]);
+ let ref = ev.tags[idx];
+ if (ref && ref[0] === "p" && ref.length > 1) {
+ let u = users.find(a => a.pubkey === ref[1]);
+ return `@${getDisplayName(u, ref[1])}`;
+ }
+ }
+ return match;
+ }).join();
+}
diff --git a/src/Pages/Login.tsx b/src/Pages/Login.tsx
index 0768af518..d32ce7c8b 100644
--- a/src/Pages/Login.tsx
+++ b/src/Pages/Login.tsx
@@ -20,7 +20,7 @@ export default function LoginPage() {
if (publicKey) {
navigate("/");
}
- }, [publicKey]);
+ }, [publicKey, navigate]);
async function getNip05PubKey(addr: string) {
let [username, domain] = addr.split("@");
@@ -32,7 +32,7 @@ export default function LoginPage() {
return pKey;
}
}
- throw "User key not found"
+ throw new Error("User key not found")
}
async function doLogin() {
@@ -43,7 +43,7 @@ export default function LoginPage() {
if (secp.utils.isValidPrivateKey(hexKey)) {
dispatch(setPrivateKey(hexKey));
} else {
- throw "INVALID PRIVATE KEY";
+ throw new Error("INVALID PRIVATE KEY");
}
} else if (key.startsWith("npub")) {
let hexKey = bech32ToHex(key);
@@ -55,7 +55,7 @@ export default function LoginPage() {
if (secp.utils.isValidPrivateKey(key)) {
dispatch(setPrivateKey(key));
} else {
- throw "INVALID PRIVATE KEY";
+ throw new Error("INVALID PRIVATE KEY");
}
}
} catch (e) {
diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx
index f0936fb4d..01c6b392d 100644
--- a/src/Pages/MessagesPage.tsx
+++ b/src/Pages/MessagesPage.tsx
@@ -8,6 +8,7 @@ import { hexToBech32 } from "../Util";
import { incDmInteraction } from "State/Login";
import { RootState } from "State/Store";
import NoteToSelf from "Element/NoteToSelf";
+import useModeration from "Hooks/useModeration";
type DmChat = {
pubkey: HexKey,
@@ -20,9 +21,10 @@ export default function MessagesPage() {
const myPubKey = useSelector(s => s.login.publicKey);
const dms = useSelector(s => s.login.dms);
const dmInteraction = useSelector(s => s.login.dmInteraction);
+ const { isMuted } = useModeration();
const chats = useMemo(() => {
- return extractChats(dms, myPubKey!);
+ return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!)
}, [dms, myPubKey, dmInteraction]);
function noteToSelf(chat: DmChat) {
@@ -91,7 +93,7 @@ export function isToSelf(e: RawEvent, pk: HexKey) {
}
export function dmsInChat(dms: RawEvent[], pk: HexKey) {
- return dms.filter(a => a.pubkey === pk || dmTo(a) == pk);
+ return dms.filter(a => a.pubkey === pk || dmTo(a) === pk);
}
export function totalUnread(dms: RawEvent[], myPubKey: HexKey) {
diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx
index 8a04440f9..d86b9be3a 100644
--- a/src/Pages/ProfilePage.tsx
+++ b/src/Pages/ProfilePage.tsx
@@ -10,6 +10,7 @@ import useProfile from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
+import LogoutButton from "Element/LogoutButton";
import Timeline from "Element/Timeline";
import Text from 'Element/Text'
import LNURLTip from "Element/LNURLTip";
@@ -17,6 +18,8 @@ import Nip05 from "Element/Nip05";
import Copy from "Element/Copy";
import ProfilePreview from "Element/ProfilePreview";
import FollowersList from "Element/FollowersList";
+import BlockList from "Element/BlockList";
+import MutedList from "Element/MutedList";
import FollowsList from "Element/FollowsList";
import { RootState } from "State/Store";
import { HexKey } from "Nostr";
@@ -26,7 +29,9 @@ enum ProfileTab {
Notes = "Notes",
Reactions = "Reactions",
Followers = "Followers",
- Follows = "Follows"
+ Follows = "Follows",
+ Muted = "Muted",
+ Blocked = "Blocked"
};
export default function ProfilePage() {
@@ -103,7 +108,7 @@ export default function ProfilePage() {
function tabContent() {
switch (tab) {
case ProfileTab.Notes:
- return ;
+ return ;
case ProfileTab.Follows: {
if (isMe) {
return (
@@ -119,6 +124,12 @@ export default function ProfilePage() {
case ProfileTab.Followers: {
return
}
+ case ProfileTab.Muted: {
+ return isMe ? :
+ }
+ case ProfileTab.Blocked: {
+ return isMe ? : null
+ }
}
}
@@ -136,9 +147,12 @@ export default function ProfilePage() {
{username()}
{isMe ? (
+ <>
+
+ >
) : (
!loggedOut && (
<>
@@ -155,6 +169,10 @@ export default function ProfilePage() {
)
}
+ function renderTab(v: ProfileTab) {
+ return
setTab(v)}>{v}
+ }
+
return (
<>
@@ -165,9 +183,8 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {
- return
setTab(v)}>{v}
- })}
+ {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
+ {isMe && renderTab(ProfileTab.Blocked)}
{tabContent()}
>
diff --git a/src/Pages/settings/Index.tsx b/src/Pages/settings/Index.tsx
index 5fc6ae1b0..d6b9877d7 100644
--- a/src/Pages/settings/Index.tsx
+++ b/src/Pages/settings/Index.tsx
@@ -1,12 +1,23 @@
-import { faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useNavigate } from "react-router-dom";
import "./Index.css";
+import { useDispatch } from "react-redux";
+import { useNavigate } from "react-router-dom";
+import { faRightFromBracket, faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { logout } from "State/Login";
+
const SettingsIndex = () => {
+ const dispatch = useDispatch();
const navigate = useNavigate();
+ function handleLogout() {
+ dispatch(logout())
+ navigate("/")
+ }
+
return (
+ <>
navigate("profile")}>
@@ -24,7 +35,12 @@ const SettingsIndex = () => {
Donate
+
+
+ Log Out
+
+ >
)
}
diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx
index bd96889fa..5e95d27eb 100644
--- a/src/Pages/settings/Profile.tsx
+++ b/src/Pages/settings/Profile.tsx
@@ -2,7 +2,7 @@ import "./Profile.css";
import Nostrich from "nostrich.jpg";
import { useEffect, useState } from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons";
@@ -10,7 +10,7 @@ import { faShop } from "@fortawesome/free-solid-svg-icons";
import useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload";
-import { logout } from "State/Login";
+import LogoutButton from "Element/LogoutButton";
import { hexToBech32, openFile } from "Util";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
@@ -20,7 +20,6 @@ export default function ProfileSettings() {
const navigate = useNavigate();
const id = useSelector(s => s.login.publicKey);
const privKey = useSelector(s => s.login.privateKey);
- const dispatch = useDispatch();
const user = useProfile(id)?.get(id || "");
const publisher = useEventPublisher();
@@ -143,7 +142,7 @@ export default function ProfileSettings() {
-
+
diff --git a/src/State/Login.ts b/src/State/Login.ts
index ca009db47..a7fac58c0 100644
--- a/src/State/Login.ts
+++ b/src/State/Login.ts
@@ -3,12 +3,20 @@ import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const';
import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection';
+import type { AppDispatch, RootState } from "State/Store";
const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read";
const UserPreferencesKey = "preferences";
+export interface NotificationRequest {
+ title: string
+ body: string
+ icon: string
+ timestamp: number
+}
+
export interface UserPreferences {
/**
* Enable reactions / reposts / zaps
@@ -72,6 +80,26 @@ export interface LoginStore {
*/
follows: HexKey[],
+ /**
+ * Newest relay list timestamp
+ */
+ latestFollows: number,
+
+ /**
+ * A list of pubkeys this user has muted
+ */
+ muted: HexKey[],
+
+ /**
+ * Last seen mute list event timestamp
+ */
+ latestMuted: number,
+
+ /**
+ * A list of pubkeys this user has muted privately
+ */
+ blocked: HexKey[],
+
/**
* Notifications for this login session
*/
@@ -105,6 +133,10 @@ const InitState = {
relays: {},
latestRelays: 0,
follows: [],
+ latestFollows: 0,
+ muted: [],
+ blocked: [],
+ latestMuted: 0,
notifications: [],
readNotifications: new Date().getTime(),
dms: [],
@@ -124,6 +156,11 @@ export interface SetRelaysPayload {
createdAt: number
};
+export interface SetFollowsPayload {
+ keys: HexKey[]
+ createdAt: number
+};
+
const LoginSlice = createSlice({
name: "Login",
initialState: InitState,
@@ -192,9 +229,14 @@ const LoginSlice = createSlice({
delete state.relays[action.payload];
state.relays = { ...state.relays };
},
- setFollows: (state, action: PayloadAction) => {
+ setFollows: (state, action: PayloadAction) => {
+ const { keys, createdAt } = action.payload
+ if (state.latestFollows > createdAt) {
+ return;
+ }
+
let existing = new Set(state.follows);
- let update = Array.isArray(action.payload) ? action.payload : [action.payload];
+ let update = Array.isArray(keys) ? keys : [keys];
let changes = false;
for (let pk of update) {
@@ -205,26 +247,24 @@ const LoginSlice = createSlice({
}
if (changes) {
state.follows = Array.from(existing);
+ state.latestFollows = createdAt;
}
},
- addNotifications: (state, action: PayloadAction) => {
- let n = action.payload;
- if (!Array.isArray(n)) {
- n = [n];
- }
-
- let didChange = false;
- for (let x of n) {
- if (!state.notifications.some(a => a.id === x.id)) {
- state.notifications.push(x);
- didChange = true;
- }
- }
- if (didChange) {
- state.notifications = [
- ...state.notifications
- ];
- }
+ setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
+ const { createdAt, keys } = action.payload
+ if (createdAt >= state.latestMuted) {
+ const muted = new Set([...keys])
+ state.muted = Array.from(muted)
+ state.latestMuted = createdAt
+ }
+ },
+ setBlocked(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
+ const { createdAt, keys } = action.payload
+ if (createdAt >= state.latestMuted) {
+ const blocked = new Set([...keys])
+ state.blocked = Array.from(blocked)
+ state.latestMuted = createdAt
+ }
},
addDirectMessage: (state, action: PayloadAction>) => {
let n = action.payload;
@@ -239,6 +279,7 @@ const LoginSlice = createSlice({
didChange = true;
}
}
+
if (didChange) {
state.dms = [
...state.dms
@@ -272,11 +313,36 @@ export const {
setRelays,
removeRelay,
setFollows,
- addNotifications,
+ setMuted,
+ setBlocked,
addDirectMessage,
incDmInteraction,
logout,
markNotificationsRead,
- setPreferences
+ setPreferences,
} = LoginSlice.actions;
-export const reducer = LoginSlice.reducer;
\ No newline at end of file
+
+export function sendNotification({ title, body, icon, timestamp }: NotificationRequest) {
+ return async (dispatch: AppDispatch, getState: () => RootState) => {
+ const state = getState()
+ const { readNotifications } = state.login
+ const hasPermission = "Notification" in window && Notification.permission === "granted"
+ const shouldShowNotification = hasPermission && timestamp > readNotifications
+ if (shouldShowNotification) {
+ try {
+ let worker = await navigator.serviceWorker.ready;
+ worker.showNotification(title, {
+ tag: "notification",
+ vibrate: [500],
+ body,
+ icon,
+ timestamp,
+ });
+ } catch (error) {
+ console.warn(error)
+ }
+ }
+ }
+}
+
+export const reducer = LoginSlice.reducer;
diff --git a/src/index.css b/src/index.css
index 3b953bcb6..afe45b1b8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -124,10 +124,15 @@ button:disabled {
cursor: not-allowed;
color: var(--gray);
}
-.light button:disabled {
+
+.light button.transparent {
color: var(--font-color);
}
+.light button:disabled {
+ color: var(--font-secondary-color);
+ border-color: var(--font-secondary-color);
+}
button:hover {
background-color: var(--font-color);
@@ -392,27 +397,6 @@ body.scroll-lock {
margin-right: auto;
}
-.tabs {
- display: flex;
- align-content: center;
- text-align: center;
- margin: 10px 0;
- overflow-x: auto;
-}
-
-.tabs>div {
- margin-right: 10px;
- cursor: pointer;
-}
-
-.tabs>div:last-child {
- margin: 0;
-}
-
-.tabs .active {
- font-weight: 700;
-}
-
.error {
color: var(--error);
}
@@ -426,12 +410,27 @@ body.scroll-lock {
}
.tabs {
- padding: 0;
- align-items: center;
- justify-content: flex-start;
+ display: flex;
+ align-content: center;
+ text-align: center;
+ margin-top: 10px;
+ overflow-x: auto;
margin-bottom: 16px;
}
+.tabs > * {
+ margin-right: 10px;
+ cursor: pointer;
+}
+
+.tabs > *:last-child {
+ margin: 0;
+}
+
+.tabs .active {
+ font-weight: 700;
+}
+
.tab {
border-bottom: 1px solid var(--gray-secondary);
font-weight: 700;