feat: push notifications
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Kieran 2023-10-19 13:28:14 +01:00
parent c823cd314d
commit b5e9203742
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
21 changed files with 159 additions and 162 deletions

View File

@ -2,7 +2,7 @@ import "./LinkPreview.css";
import { CSSProperties, useEffect, useState } from "react";
import Spinner from "Icons/Spinner";
import SnortApi, { LinkPreviewData } from "SnortApi";
import SnortApi, { LinkPreviewData } from "External/SnortApi";
import useImgProxy from "Hooks/useImgProxy";
import { MediaElement } from "Element/Embed/MediaElement";

View File

@ -2,12 +2,11 @@ import { NostrEvent } from "@snort/system";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { LNURL } from "@snort/shared";
import { dedupe, findTag, hexToBech32 } from "SnortUtils";
import { dedupe, findTag, hexToBech32, getDisplayName } from "SnortUtils";
import FollowListBase from "Element/User/FollowListBase";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
import { Toastore } from "Toaster";
import { getDisplayName } from "Element/User/DisplayName";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import useEventPublisher from "Hooks/useEventPublisher";

View File

@ -9,7 +9,7 @@ import classNames from "classnames";
import { formatShort } from "Number";
import useEventPublisher from "Hooks/useEventPublisher";
import { delay, findTag } from "SnortUtils";
import { delay, findTag, getDisplayName } from "SnortUtils";
import { NoteCreator } from "Element/Event/NoteCreator";
import SendSats from "Element/SendSats";
import { ZapsSummary } from "Element/Event/Zap";
@ -20,7 +20,6 @@ import useLogin from "Hooks/useLogin";
import { useInteractionCache } from "Hooks/useInteractionCache";
import { ZapPoolController } from "ZapPoolController";
import { Zapper, ZapTarget } from "Zapper";
import { getDisplayName } from "Element/User/DisplayName";
import { useNoteCreator } from "State/NoteCreator";
import Icon from "Icons/Icon";

View File

@ -4,8 +4,7 @@ import { useMemo } from "react";
import { EventKind, NostrEvent, TaggedNostrEvent, NostrPrefix, EventExt } from "@snort/system";
import Note from "Element/Event/Note";
import { getDisplayName } from "Element/User/DisplayName";
import { eventLink, hexToBech32 } from "SnortUtils";
import { eventLink, hexToBech32, getDisplayName } from "SnortUtils";
import useModeration from "Hooks/useModeration";
import { FormattedMessage } from "react-intl";
import Icon from "Icons/Icon";

View File

@ -5,8 +5,7 @@ import type { UserMetadata } from "@snort/system";
import classNames from "classnames";
import useImgProxy from "Hooks/useImgProxy";
import { getDisplayName } from "Element/User/DisplayName";
import { defaultAvatar } from "SnortUtils";
import { defaultAvatar, getDisplayName } from "SnortUtils";
interface AvatarProps {
pubkey: string;

View File

@ -1,35 +1,14 @@
import "./DisplayName.css";
import { useMemo } from "react";
import { HexKey, UserMetadata, NostrPrefix } from "@snort/system";
import AnimalName from "Element/User/AnimalName";
import { hexToBech32 } from "SnortUtils";
import { HexKey, UserMetadata } from "@snort/system";
import { getDisplayNameOrPlaceHolder } from "SnortUtils";
interface DisplayNameProps {
pubkey: HexKey;
user: UserMetadata | undefined;
}
export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey): string {
return getDisplayNameOrPlaceHolder(user, pubkey)[0];
}
export function getDisplayNameOrPlaceHolder(user: UserMetadata | undefined, pubkey: HexKey): [string, boolean] {
let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12);
let isPlaceHolder = false;
if (typeof user?.display_name === "string" && user.display_name.length > 0) {
name = user.display_name;
} else if (typeof user?.name === "string" && user.name.length > 0) {
name = user.name;
} else if (pubkey && CONFIG.animalNamePlaceholders) {
name = AnimalName(pubkey);
isPlaceHolder = true;
}
return [name.trim(), isPlaceHolder];
}
const DisplayName = ({ pubkey, user }: DisplayNameProps) => {
const [name, isPlaceHolder] = useMemo(() => getDisplayNameOrPlaceHolder(user, pubkey), [user, pubkey]);

View File

@ -47,6 +47,12 @@ export interface LinkPreviewData {
og_tags?: Array<[name: string, value: string]>;
}
export interface PushNotifications {
endpoint: string;
p256dh: string;
auth: string;
}
export default class SnortApi {
#url: string;
#publisher?: EventPublisher;
@ -88,10 +94,18 @@ export default class SnortApi {
return this.#getJson<{ address: string }>("p/on-chain");
}
getPushNotificationInfo() {
return this.#getJson<{ publicKey: string }>("api/v1/notifications/info");
}
registerPushNotifications(sub: PushNotifications) {
return this.#getJsonAuthd<void>("api/v1/notifications/register", "POST", sub);
}
async #getJsonAuthd<T>(
path: string,
method?: "GET" | string,
body?: { [key: string]: string },
body?: object,
headers?: { [key: string]: string },
): Promise<T> {
if (!this.#publisher) {
@ -113,7 +127,7 @@ export default class SnortApi {
async #getJson<T>(
path: string,
method?: "GET" | string,
body?: { [key: string]: string },
body?: object,
headers?: { [key: string]: string },
): Promise<T> {
const rsp = await fetch(`${this.#url}${path}`, {
@ -126,10 +140,19 @@ export default class SnortApi {
},
});
const obj = await rsp.json();
if ("error" in obj) {
throw new SubscriptionError(obj.error, obj.code);
if (rsp.ok) {
const text = await rsp.text();
if (text.length > 0) {
const obj = JSON.parse(text);
if ("error" in obj) {
throw new SubscriptionError(obj.error, obj.code);
}
return obj as T;
} else {
return {} as T;
}
} else {
throw new Error("Invalid response");
}
return obj as T;
}
}

View File

@ -1,7 +1,6 @@
import { TaggedNostrEvent, EventKind, MetadataCache } from "@snort/system";
import { getDisplayName } from "Element/User/DisplayName";
import { MentionRegex } from "Const";
import { defaultAvatar, tagFilterOfTextRepost } from "SnortUtils";
import { defaultAvatar, tagFilterOfTextRepost, getDisplayName } from "SnortUtils";
import { UserCache } from "Cache";
import { LoginSession } from "Login";
import { removeUndefined } from "@snort/shared";

View File

@ -6,7 +6,7 @@ import { ApiHost, DeveloperAccounts, SnortPubKey } from "Const";
import ProfilePreview from "Element/User/ProfilePreview";
import ZapButton from "Element/Event/ZapButton";
import { bech32ToHex } from "SnortUtils";
import SnortApi, { RevenueSplit, RevenueToday } from "SnortApi";
import SnortApi, { RevenueSplit, RevenueToday } from "External/SnortApi";
import Modal from "Element/Modal";
import AsyncButton from "Element/AsyncButton";
import QrCode from "Element/QrCode";

View File

@ -22,6 +22,10 @@ import { LoginStore } from "Login";
import { NoteCreatorButton } from "Element/Event/NoteCreatorButton";
import { ProfileLink } from "Element/User/ProfileLink";
import SearchBox from "../Element/SearchBox";
import SnortApi from "External/SnortApi";
import useEventPublisher from "Hooks/useEventPublisher";
import { base64 } from "@scure/base";
import { unwrap } from "@snort/shared";
export default function Layout() {
const location = useLocation();
@ -104,6 +108,7 @@ const AccountHeader = () => {
readonly: s.readonly,
}));
const profile = useUserProfile(publicKey);
const { publisher } = useEventPublisher();
const hasNotifications = useMemo(
() => latestNotification > readNotifications,
@ -123,6 +128,25 @@ const AccountHeader = () => {
console.error(e);
}
}
try {
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.ready;
if (reg && publisher) {
const api = new SnortApi(undefined, publisher);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: (await api.getPushNotificationInfo()).publicKey,
});
await api.registerPushNotifications({
endpoint: sub.endpoint,
p256dh: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))),
auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))),
});
}
}
} catch (e) {
console.error(e);
}
}
if (!publicKey) {

View File

@ -8,7 +8,7 @@ import { useUserProfile, useUserSearch } from "@snort/system-react";
import UnreadCount from "Element/UnreadCount";
import ProfileImage from "Element/User/ProfileImage";
import { appendDedupe, debounce, parseId } from "SnortUtils";
import { appendDedupe, debounce, parseId, getDisplayName } from "SnortUtils";
import NoteToSelf from "Element/User/NoteToSelf";
import useModeration from "Hooks/useModeration";
import useLogin from "Hooks/useLogin";
@ -25,7 +25,6 @@ import { useEventFeed } from "Feed/EventFeed";
import { LoginSession, LoginStore } from "Login";
import { Nip28ChatSystem } from "chat/nip28";
import { ChatParticipantProfile } from "Element/Chat/ChatParticipant";
import { getDisplayName } from "Element/User/DisplayName";
import classNames from "classnames";
const TwoCol = 768;

View File

@ -11,7 +11,7 @@ import { Bar, BarChart, Tooltip, XAxis, YAxis } from "recharts";
import useLogin from "Hooks/useLogin";
import { markNotificationsRead } from "Login";
import { Notifications, UserCache } from "Cache";
import { dedupe, findTag, orderAscending, orderDescending } from "SnortUtils";
import { dedupe, findTag, orderAscending, orderDescending, getDisplayName } from "SnortUtils";
import Icon from "Icons/Icon";
import ProfileImage from "Element/User/ProfileImage";
import useModeration from "Hooks/useModeration";
@ -20,7 +20,6 @@ import Text from "Element/Text";
import { formatShort } from "Number";
import { LiveEvent } from "Element/LiveEvent";
import ProfilePreview from "Element/User/ProfilePreview";
import { getDisplayName } from "Element/User/DisplayName";
import { Day } from "Const";
import Tabs, { Tab } from "Element/Tabs";
import classNames from "classnames";

View File

@ -1,106 +0,0 @@
import { useMemo, useState } from "react";
import { useIntl, FormattedMessage } from "react-intl";
import { useNavigate } from "react-router-dom";
import { ApiHost } from "Const";
import Logo from "Element/Logo";
import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/User/FollowListBase";
import { bech32ToHex } from "SnortUtils";
import SnortApi from "SnortApi";
import useLogin from "Hooks/useLogin";
import messages from "./messages";
export default function ImportFollows() {
const navigate = useNavigate();
const currentFollows = useLogin().follows;
const { formatMessage } = useIntl();
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
const [error, setError] = useState<string>("");
const api = new SnortApi(ApiHost);
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a)).sort(a => (currentFollows.item.includes(a) ? 1 : -1));
}, [follows, currentFollows]);
async function loadFollows() {
setFollows([]);
setError("");
try {
const rsp = await api.twitterImport(twitterUsername);
if (Array.isArray(rsp) && rsp.length === 0) {
setError(formatMessage(messages.NoUsersFound, { twitterUsername }));
} else {
setFollows(rsp);
}
} catch (e) {
console.warn(e);
if (e instanceof Error) {
setError(e.message);
} else {
setError(formatMessage(messages.FailedToLoad));
}
}
}
return (
<div className="main-content new-user" dir="auto">
<Logo />
<div className="progress-bar">
<div className="progress progress-last"></div>
</div>
<h1>
<FormattedMessage {...messages.ImportTwitter} />
</h1>
<p>
<FormattedMessage
{...messages.FindYourFollows}
values={{
provider: (
<a href="https://nostr.directory" target="_blank" rel="noreferrer">
nostr.directory
</a>
),
}}
/>
</p>
<div className="next-actions continue-actions">
<button type="button" onClick={() => navigate("/new/discover")}>
<FormattedMessage {...messages.Next} />
</button>
</div>
<h2>
<FormattedMessage {...messages.TwitterUsername} />
</h2>
<div className="flex">
<input
type="text"
placeholder={formatMessage(messages.TwitterPlaceholder)}
className="grow mr10"
value={twitterUsername}
onChange={e => setTwitterUsername(e.target.value)}
/>
<AsyncButton type="button" className="secondary tall" onClick={loadFollows}>
<FormattedMessage {...messages.Check} />
</AsyncButton>
</div>
{error.length > 0 && <b className="error">{error}</b>}
<div dir="ltr">
{sortedTwitterFollows.length > 0 && (
<FollowListBase
title={
<h2>
<FormattedMessage {...messages.FollowsOnNostr} values={{ username: twitterUsername }} />
</h2>
}
pubkeys={sortedTwitterFollows}
/>
)}
</div>
</div>
);
}

View File

@ -4,11 +4,9 @@ import { RouteObject } from "react-router-dom";
import GetVerified from "Pages/new/GetVerified";
import ProfileSetup from "Pages/new/ProfileSetup";
import NewUserFlow from "Pages/new/NewUserFlow";
import ImportFollows from "Pages/new/ImportFollows";
import DiscoverFollows from "Pages/new/DiscoverFollows";
export const PROFILE = "/new/profile";
export const IMPORT = "/new/import";
export const DISCOVER = "/new/discover";
export const VERIFY = "/new/verify";
@ -21,10 +19,6 @@ export const NewUserRoutes: RouteObject[] = [
path: PROFILE,
element: <ProfileSetup />,
},
{
path: IMPORT,
element: <ImportFollows />,
},
{
path: VERIFY,
element: <GetVerified />,

View File

@ -4,7 +4,7 @@ import { Link, useNavigate } from "react-router-dom";
import PageSpinner from "Element/PageSpinner";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import SnortApi, { Subscription, SubscriptionError } from "External/SnortApi";
import { mapSubscriptionErrorCode } from ".";
import SubscriptionCard from "./SubscriptionCard";

View File

@ -5,7 +5,7 @@ import { unixNow, unwrap } from "@snort/shared";
import AsyncButton from "Element/AsyncButton";
import SendSats from "Element/SendSats";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { Subscription, SubscriptionError } from "SnortApi";
import SnortApi, { Subscription, SubscriptionError } from "External/SnortApi";
import { mapPlanName, mapSubscriptionErrorCode } from ".";
import useLogin from "Hooks/useLogin";
import { mostRecentSubscription } from "Subscription";

View File

@ -1,6 +1,6 @@
import { FormattedMessage, FormattedDate, FormattedNumber } from "react-intl";
import { Subscription } from "SnortApi";
import { Subscription } from "External/SnortApi";
import { mapPlanName } from ".";
import Icon from "Icons/Icon";
import Nip5Service from "Element/Nip5Service";

View File

@ -9,7 +9,7 @@ import { LockedFeatures, Plans, SubscriptionType } from "Subscription";
import ManageSubscriptionPage from "Pages/subscribe/ManageSubscription";
import AsyncButton from "Element/AsyncButton";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "SnortApi";
import SnortApi, { SubscriptionError, SubscriptionErrorCode } from "External/SnortApi";
import SendSats from "Element/SendSats";
import classNames from "classnames";

View File

@ -14,8 +14,10 @@ import {
NostrEvent,
MetadataCache,
NostrLink,
UserMetadata,
} from "@snort/system";
import { Day } from "Const";
import AnimalName from "Element/User/AnimalName";
export const sha256 = (str: string | Uint8Array): u256 => {
return utils.bytesToHex(hash(str));
@ -495,3 +497,23 @@ export const isChristmas = () => {
const event = new Date(ThisYear, 11, 25);
return IsTheSeason(event);
};
export function getDisplayName(user: UserMetadata | undefined, pubkey: HexKey): string {
return getDisplayNameOrPlaceHolder(user, pubkey)[0];
}
export function getDisplayNameOrPlaceHolder(user: UserMetadata | undefined, pubkey: HexKey): [string, boolean] {
let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12);
let isPlaceHolder = false;
if (typeof user?.display_name === "string" && user.display_name.length > 0) {
name = user.display_name;
} else if (typeof user?.name === "string" && user.name.length > 0) {
name = user.name;
} else if (pubkey && CONFIG.animalNamePlaceholders) {
name = AnimalName(pubkey);
isPlaceHolder = true;
}
return [name.trim(), isPlaceHolder];
}

View File

@ -1,8 +1,8 @@
import { UserCache } from "Cache";
import { getDisplayName } from "Element/User/DisplayName";
import { LNURL, ExternalStore, unixNow } from "@snort/shared";
import { Toastore } from "Toaster";
import { LNWallet, WalletInvoiceState, Wallets } from "Wallet";
import { getDisplayName } from "SnortUtils";
export enum ZapPoolRecipientType {
Generic = 0,

View File

@ -3,6 +3,8 @@ declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: (string | PrecacheEntry)[];
};
import { NostrEvent, NostrPrefix, mapEventToProfile, tryParseNostrLink } from "@snort/system";
import { defaultAvatar, getDisplayName } from "SnortUtils";
import { clientsClaim } from "workbox-core";
import { PrecacheEntry, precacheAndRoute } from "workbox-precaching";
@ -14,3 +16,69 @@ self.addEventListener("message", event => {
self.skipWaiting();
}
});
const enum PushType {
Mention = 1,
}
interface PushNotification {
type: PushType;
data: object;
}
interface PushNotificationMention {
profiles: Array<NostrEvent>;
events: Array<NostrEvent>;
}
self.addEventListener("push", async e => {
console.debug(e);
const data = e.data?.json() as PushNotification | undefined;
console.debug(data);
if (data) {
switch (data.type) {
case PushType.Mention: {
const mention = data.data as PushNotificationMention;
for (const ev of mention.events) {
const userEvent = mention.profiles.find(a => a.pubkey === ev.pubkey);
const userProfile = userEvent ? mapEventToProfile(userEvent) : undefined;
const avatarUrl = userProfile?.picture ?? defaultAvatar(ev.pubkey);
const notif = {
title: `Reply from ${getDisplayName(userProfile, ev.pubkey)}`,
body: replaceMentions(ev.content, mention.profiles).substring(0, 250),
icon: avatarUrl,
timestamp: ev.created_at * 1000,
};
console.debug("Sending notification", notif);
await self.registration.showNotification(notif.title, {
tag: "notification",
vibrate: [500],
...notif,
});
}
break;
}
}
}
});
const MentionNostrEntityRegex = /@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g;
function replaceMentions(content: string, profiles: Array<NostrEvent>) {
return content
.split(MentionNostrEntityRegex)
.map(i => {
if (MentionNostrEntityRegex.test(i)) {
const link = tryParseNostrLink(i);
if (link?.type === NostrPrefix.PublicKey || link?.type === NostrPrefix.Profile) {
const px = profiles.find(a => a.pubkey === link.id);
const profile = px && mapEventToProfile(px);
return `@${getDisplayName(profile, link.id)}`;
}
}
return i;
})
.join("");
}