Merge pull request #54 from v0l/dms

DM's
This commit is contained in:
Kieran 2023-01-14 12:27:49 +00:00 committed by GitHub
commit b43f01ff4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 464 additions and 56 deletions

View File

@ -7,6 +7,7 @@
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@noble/secp256k1": "^1.7.0",
"@protobufjs/base64": "^1.1.2",
"@reduxjs/toolkit": "^1.9.1",
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",

View File

@ -6,6 +6,7 @@ import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlReg
import { eventLink, hexToBech32, profileLink } from "./Util";
import LazyImage from "./element/LazyImage";
import Hashtag from "./element/Hashtag";
import { useMemo } from "react";
function transformHttpLink(a) {
try {
@ -135,3 +136,19 @@ export function extractHashtags(fragments) {
return f;
}).flat();
}
export default function Text({ content, transforms }) {
const transformed = useMemo(() => {
let fragments = [content];
transforms?.forEach(a => {
fragments = a(fragments);
});
fragments = extractLinks(fragments);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
return fragments;
}, [content]);
return transformed;
}

28
src/element/DM.css Normal file
View File

@ -0,0 +1,28 @@
.dm {
padding: 8px;
background-color: var(--gray);
margin-bottom: 5px;
border-radius: 5px;
width: fit-content;
min-width: 100px;
max-width: 90%;
overflow: hidden;
min-height: 40px;
white-space: pre-wrap;
}
.dm > div:first-child {
color: var(--gray-light);
font-size: small;
margin-bottom: 3px;
}
.dm.me {
align-self: flex-end;
background-color: var(--gray-secondary);
}
.dm img, .dm video, .dm iframe {
max-width: 100%;
max-height: 500px;
}

47
src/element/DM.tsx Normal file
View File

@ -0,0 +1,47 @@
import "./DM.css";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { useInView } from 'react-intersection-observer';
// @ts-ignore
import useEventPublisher from "../feed/EventPublisher";
// @ts-ignore
import Event from "../nostr/Event";
// @ts-ignore
import NoteTime from "./NoteTime";
// @ts-ignore
import Text from "../Text";
export type DMProps = {
data: any
}
export default function DM(props: DMProps) {
const pubKey = useSelector<any>(s => s.login.publicKey);
const publisher = useEventPublisher();
const [content, setContent] = useState("Loading...");
const [decrypted, setDecrypted] = useState(false);
const { ref, inView, entry } = useInView();
async function decrypt() {
let e = Event.FromObject(props.data);
let decrypted = await publisher.decryptDm(e);
setContent(decrypted);
}
useEffect(() => {
if (!decrypted && inView) {
setDecrypted(true);
decrypt().catch(console.error);
}
}, [inView, props.data]);
return (
<div className={`flex dm f-col${props.data.pubkey === pubKey ? " me" : ""}`} ref={ref}>
<div><NoteTime from={props.data.created_at * 1000} /></div>
<div className="w-max">
<Text content={content} />
</div>
</div>
)
}

View File

@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import Event from "../nostr/Event";
import ProfileImage from "./ProfileImage";
import { extractLinks, extractMentions, extractInvoices, extractHashtags } from "../Text";
import Text, { extractMentions } from "../Text";
import { eventLink, hexToBech32 } from "../Util";
import NoteFooter from "./NoteFooter";
import NoteTime from "./NoteTime";
@ -28,19 +28,13 @@ export default function Note(props) {
const transformBody = useCallback(() => {
let body = ev?.Content ?? "";
let fragments = extractLinks([body]);
fragments = extractMentions(fragments, ev.Tags, users);
fragments = extractInvoices(fragments);
fragments = extractHashtags(fragments);
if (deletion?.length > 0) {
return (
<>
<b className="error">Deleted</b>
</>
);
return (<b className="error">Deleted</b>);
}
return fragments;
const mentions = (fragments) => {
return extractMentions(fragments, ev.Tags, users);
}
return <Text content={body} transforms={[mentions]} />;
}, [data, dataEvent, reactions, deletion]);
function goToEvent(e, id) {

View File

@ -4,8 +4,7 @@ const MinuteInMs = 1_000 * 60;
const HourInMs = MinuteInMs * 60;
const DayInMs = HourInMs * 24;
export default function NoteTime(props) {
const from = props.from;
export default function NoteTime({ from }) {
const [time, setTime] = useState("");
function calcTime() {

View File

@ -7,7 +7,7 @@ import useProfile from "../feed/ProfileFeed";
import { hexToBech32, profileLink } from "../Util";
import LazyImage from "./LazyImage";
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className }) {
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }) {
const navigate = useNavigate();
const user = useProfile(pubkey);
@ -23,12 +23,12 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
}, [user]);
return (
<div className={`pfp ${className ?? ""}`}>
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(profileLink(pubkey))} />
<div className={`pfp ${className}`}>
<LazyImage src={hasImage ? user.picture : Nostrich} onClick={() => navigate(link ?? profileLink(pubkey))} />
{showUsername && (<div className="f-grow">
<Link key={pubkey} to={profileLink(pubkey)}>{name}</Link>
<Link key={pubkey} to={link ?? profileLink(pubkey)}>{name}</Link>
{subHeader ? <>{subHeader}</> : null}
</div>
</div>
)}
</div>
)

View File

@ -12,9 +12,6 @@
padding: 5px;
}
.relay > div:first-child {
}
.relay-extra {
padding: 5px;
margin: 0 5px;

View File

@ -20,7 +20,7 @@ export default function useEventPublisher() {
async function signEvent(ev) {
if (hasNip07 && !privKey) {
ev.Id = await ev.CreateId();
let tmpEv = await window.nostr.signEvent(ev.ToObject());
let tmpEv = await barierNip07(() => window.nostr.signEvent(ev.ToObject()));
return Event.FromObject(tmpEv);
} else {
await ev.Sign(privKey);
@ -72,7 +72,7 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["e", replyTo.Id, "", "reply"], ev.Tags.length));
ev.Tags.push(new Tag(["p", replyTo.PubKey], ev.Tags.length));
for (let pk of thread.PubKeys) {
if(pk === pubKey) {
if (pk === pubKey) {
continue; // dont tag self in replies
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
@ -152,6 +152,63 @@ export default function useEventPublisher() {
ev.Tags.push(new Tag(["e", note.Id]));
ev.Tags.push(new Tag(["p", note.PubKey]));
return await signEvent(ev);
},
decryptDm: async (note) => {
if (note.PubKey !== pubKey && !note.Tags.some(a => a.PubKey === pubKey)) {
return "<CANT DECRYPT>";
}
try {
let otherPubKey = note.PubKey === pubKey ? note.Tags.filter(a => a.Key === "p")[0].PubKey : note.PubKey;
if (hasNip07 && !privKey) {
return await barierNip07(() => window.nostr.nip04.decrypt(otherPubKey, note.Content));
} else if(privKey) {
await note.DecryptDm(privKey, otherPubKey);
return note.Content;
}
} catch (e) {
console.error("Decyrption failed", e);
return "<DECRYPTION FAILED>";
}
return "test";
},
sendDm: async (content, to) => {
let ev = Event.ForPubKey(pubKey);
ev.Kind = EventKind.DirectMessage;
ev.Content = content;
ev.Tags.push(new Tag(["p", to]));
try {
if (hasNip07 && !privKey) {
let cx = await barierNip07(() => window.nostr.nip04.encrypt(to, content));
ev.Content = cx;
return await signEvent(ev);
} else if(privKey) {
await ev.EncryptDmForPubkey(to, privKey);
return await signEvent(ev);
}
} catch (e) {
console.error("Encryption failed", e);
}
}
}
}
}
let isNip07Busy = false;
const delay = (t) => {
return new Promise((resolve, reject) => {
setTimeout(resolve, t);
});
}
const barierNip07 = async (then) => {
while (isNip07Busy) {
await delay(10);
}
isNip07Busy = true;
try {
return await then();
} finally {
isNip07Busy = false;
}
};

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import EventKind from "../nostr/EventKind";
import { Subscriptions } from "../nostr/Subscriptions";
import { addNotifications, setFollows, setRelays } from "../state/Login";
import { addDirectMessage, addNotifications, setFollows, setRelays } from "../state/Login";
import { setUserData } from "../state/Users";
import useSubscription from "./Subscription";
import { mapEventToProfile } from "./UsersFeed";
@ -24,9 +24,11 @@ export default function useLoginFeed() {
sub.Authors.add(pubKey);
sub.Kinds.add(EventKind.ContactList);
sub.Kinds.add(EventKind.SetMetadata);
sub.Kinds.add(EventKind.DirectMessage);
let notifications = new Subscriptions();
notifications.Kinds.add(EventKind.TextNote);
notifications.Kinds.add(EventKind.DirectMessage);
notifications.PTags.add(pubKey);
notifications.Limit = 100;
sub.AddSubscription(notifications);
@ -40,6 +42,7 @@ export default function useLoginFeed() {
let contactList = notes.filter(a => a.kind === EventKind.ContactList);
let notifications = notes.filter(a => a.kind === EventKind.TextNote);
let metadata = notes.filter(a => a.kind === EventKind.SetMetadata).map(a => mapEventToProfile(a));
let dms = notes.filter(a => a.kind === EventKind.DirectMessage);
for (let cl of contactList) {
if (cl.content !== "") {
@ -58,5 +61,6 @@ export default function useLoginFeed() {
}
dispatch(addNotifications(notifications));
dispatch(setUserData(metadata));
dispatch(addDirectMessage(dms));
}, [notes]);
}

View File

@ -111,7 +111,7 @@ code {
}
.btn-rnd {
border-radius: 25px;
border-radius: 100%;
}
textarea {
@ -273,6 +273,10 @@ body.scroll-lock {
margin-right: 10px;
}
.mr5 {
margin-right: 5px;
}
.ml5 {
margin-left: 5px;
}

View File

@ -20,6 +20,8 @@ import NewUserPage from './pages/NewUserPage';
import SettingsPage from './pages/SettingsPage';
import ErrorPage from './pages/ErrorPage';
import VerificationPage from './pages/Verification';
import MessagesPage from './pages/MessagesPage';
import ChatPage from './pages/ChatPage';
/**
* Nostr websocket managment system
@ -62,6 +64,14 @@ const router = createBrowserRouter([
{
path: "/verification",
element: <VerificationPage />
},
{
path: "/messages",
element: <MessagesPage />
},
{
path: "/messages/:id",
element: <ChatPage />
}
]
}

View File

@ -211,7 +211,7 @@ export default class Connection {
this.CurrentState.events.send = this.Stats.EventsSent;
this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0;
this.CurrentState.disconnects = this.Stats.Disconnects;
this.Stats.Latency = this.Stats.Latency.slice(this.Stats.Latency.length - 20); // trim
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true;
this._NotifyState();
}

View File

@ -1,4 +1,5 @@
import * as secp from '@noble/secp256k1';
import base64 from "@protobufjs/base64"
import EventKind from "./EventKind";
import Tag from './Tag';
import Thread from './Thread';
@ -165,4 +166,48 @@ export default class Event {
ev.PubKey = pubKey;
return ev;
}
/**
* Encrypt the message content in place
* @param {string} pubkey
* @param {string} privkey
*/
async EncryptDmForPubkey(pubkey, privkey) {
let key = await this._GetDmSharedKey(pubkey, privkey);
let iv = window.crypto.getRandomValues(new Uint8Array(16));
let data = new TextEncoder().encode(this.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)}`;
}
/**
* Decrypt the content of this message in place
* @param {string} privkey
* @param {string} pubkey
*/
async DecryptDm(privkey, pubkey) {
let key = await this._GetDmSharedKey(pubkey, privkey);
let cSplit = this.Content.split("?iv=");
let data = new Uint8Array(base64.length(cSplit[0]));
base64.decode(cSplit[0], data, 0);
let iv = new Uint8Array(base64.length(cSplit[1]));
base64.decode(cSplit[1], iv, 0);
let result = await window.crypto.subtle.decrypt({
name: "AES-CBC",
iv: iv
}, key, data);
this.Content = new TextDecoder().decode(result);
}
async _GetDmSharedKey(pubkey, privkey) {
let sharedPoint = secp.getSharedSecret(privkey, '02' + pubkey);
let sharedX = sharedPoint.slice(1, 33);
return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"])
}
}

9
src/nostr/index.ts Normal file
View File

@ -0,0 +1,9 @@
export interface RawEvent {
id: string,
pubkey: string,
created_at: number,
kind: number,
tags: string[][],
content: string,
sig: string
}

33
src/pages/ChatPage.css Normal file
View File

@ -0,0 +1,33 @@
.dm-list {
overflow-y: auto;
overflow-x: hidden;
height: calc(100vh - 66px - 50px - 70px);
}
.dm-list > div {
display: flex;
flex-direction: column;
margin-bottom: 10px;
scroll-padding-bottom: 40px;
}
.write-dm {
position: fixed;
bottom: 0;
background-color: var(--gray-light);
width: inherit;
border-radius: 5px 5px 0 0;
}
.write-dm .inner {
display: flex;
align-items: center;
padding: 10px 5px;
}
.write-dm textarea {
resize: none;
}
.write-dm-spacer {
margin-bottom: 80px;
}

82
src/pages/ChatPage.tsx Normal file
View File

@ -0,0 +1,82 @@
import "./ChatPage.css";
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useParams } from "react-router-dom";
import { useInView } from 'react-intersection-observer';
// @ts-ignore
import ProfileImage from "../element/ProfileImage";
// @ts-ignore
import { bech32ToHex } from "../Util";
// @ts-ignore
import useEventPublisher from "../feed/EventPublisher";
import DM from "../element/DM";
import { RawEvent } from "../nostr";
type RouterParams = {
id: string
}
export default function ChatPage() {
const params = useParams<RouterParams>();
const publisher = useEventPublisher();
const id = bech32ToHex(params.id);
const dms = useSelector<any, RawEvent[]>(s => filterDms(s.login.dms, s.login.publicKey));
const [content, setContent] = useState<string>();
const { ref, inView, entry } = useInView();
const dmListRef = useRef<HTMLDivElement>(null);
function filterDms(dms: RawEvent[], myPubkey: string) {
return dms.filter(a => {
if (a.pubkey === myPubkey && a.tags.some(b => b[0] === "p" && b[1] === id)) {
return true;
} else if (a.pubkey === id && a.tags.some(b => b[0] === "p" && b[1] === myPubkey)) {
return true;
}
return false;
});
}
const sortedDms = useMemo<any[]>(() => {
return [...dms].sort((a, b) => a.created_at - b.created_at)
}, [dms]);
useEffect(() => {
if (inView && dmListRef.current) {
dmListRef.current.scroll(0, dmListRef.current.scrollHeight);
}
}, [inView, dmListRef, sortedDms]);
async function sendDm() {
let ev = await publisher.sendDm(content, id);
console.debug(ev);
publisher.broadcast(ev);
setContent("");
}
async function onEnter(e: KeyboardEvent) {
let isEnter = e.code === "Enter";
if(isEnter && !e.shiftKey) {
await sendDm();
}
}
return (
<>
<ProfileImage pubkey={id} className="f-grow mb10" />
<div className="dm-list" ref={dmListRef}>
<div>
{sortedDms.map(a => <DM data={a} key={a.id} />)}
<div ref={ref} className="mb10"></div>
</div>
</div>
<div className="write-dm">
<div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => onEnter(e)}></textarea>
<div className="btn" onClick={() => sendDm()}>Send</div>
</div>
</div>
</>
)
}

View File

@ -1,7 +1,15 @@
.notifications {
margin-right: 10px;
}
.unread-count {
margin-left: .2em;
}
width: 20px;
height: 20px;
border: 1px solid;
border-radius: 100%;
position: relative;
padding: 3px;
line-height: 1.5em;
top: -10px;
left: -10px;
font-size: small;
background-color: var(--error);
font-weight: bold;
text-align: center;
}

View File

@ -2,7 +2,7 @@ import "./Layout.css";
import { useEffect } from "react"
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useNavigate } from "react-router-dom";
import { faBell } from "@fortawesome/free-solid-svg-icons";
import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { System } from ".."
@ -57,14 +57,15 @@ export default function Layout(props) {
const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
return (
<>
<div className="btn btn-rnd notifications" onClick={(e) => goToNotifications(e)}>
<FontAwesomeIcon icon={faBell} size="xl" />
{unreadNotifications !== 0 && (
<span className="unread-count">
{unreadNotifications}
</span>
)}
<div className="btn btn-rnd mr10" onClick={(e) => navigate("/messages")}>
<FontAwesomeIcon icon={faMessage} size="xl" />
</div>
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}>
<FontAwesomeIcon icon={faBell} size="xl" />
</div>
{unreadNotifications > 0 && (<span className="unread-count">
{unreadNotifications > 100 ? ">99" : unreadNotifications}
</span>)}
<ProfileImage pubkey={key} showUsername={false} />
</>
)

View File

@ -0,0 +1,36 @@
import { useMemo } from "react";
import { useSelector } from "react-redux"
import { RawEvent } from "../nostr";
// @ts-ignore
import ProfileImage from "../element/ProfileImage";
// @ts-ignore
import { hexToBech32 } from "../Util";
export default function MessagesPage() {
const pubKey = useSelector<any, string>(s => s.login.publicKey);
const dms = useSelector<any, RawEvent[]>(s => s.login.dms);
const pubKeys = useMemo(() => {
return Array.from(new Set<string>(dms.map(a => [a.pubkey, ...a.tags.filter(b => b[0] === "p").map(b => b[1])]).flat()));
}, [dms]);
function person(pubkey: string) {
return (
<div className="flex mb10" key={pubkey}>
<ProfileImage pubkey={pubkey} className="f-grow" link={`/messages/${hexToBech32("npub", pubkey)}`} />
<span className="pill">
{dms?.filter(a => a.pubkey === pubkey && a.pubkey !== pubKey).length}
</span>
</div>
)
}
return (
<>
<h3>Messages</h3>
{pubKeys.map(person)}
</>
)
}

View File

@ -10,10 +10,6 @@
white-space: pre-wrap;
}
.profile .name {
align-items: flex-start;
}
.profile .name h2 {
margin: 0;
}

View File

@ -4,12 +4,12 @@ import Nostrich from "../nostrich.jpg";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faQrcode, faGear } from "@fortawesome/free-solid-svg-icons";
import { faQrcode, faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { useNavigate, useParams } from "react-router-dom";
import useProfile from "../feed/ProfileFeed";
import FollowButton from "../element/FollowButton";
import { extractLnAddress, parseId } from "../Util";
import { extractLnAddress, parseId, hexToBech32 } from "../Util";
import Timeline from "../element/Timeline";
import { extractLinks, extractHashtags } from '../Text'
import LNURLTip from "../element/LNURLTip";
@ -50,20 +50,30 @@ export default function ProfilePage() {
return (
<>
<div className="flex name">
<div className="f-grow">
<h2>{user?.display_name || user?.name}</h2>
<Copy text={params.id} />
{user?.nip05 && <Nip05 name={name} domain={domain} isVerified={isVerified} couldNotVerify={couldNotVerify} />}
</div>
<div>
<div className="f-grow f-ellipsis">
<h2>{user?.display_name || user?.name}</h2></div>
<div className="flex">
{isMe ? (
<div className="btn btn-icon" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" />
</div>
) : <FollowButton pubkey={id} />
) : <>
<div className="btn mr5" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" />
</div>
<FollowButton pubkey={id} />
</>
}
</div>
</div>
<div className="flex">
<div className="f-grow">
<Copy text={params.id} />
{user?.nip05 && <Nip05 name={name} domain={domain} isVerified={isVerified} couldNotVerify={couldNotVerify} />}
</div>
</div>
<p>{about}</p>
{user?.website && (

View File

@ -43,6 +43,11 @@ const LoginSlice = createSlice({
* Timestamp of last read notification
*/
readNotifications: 0,
/**
* Encrypted DM's
*/
dms: []
},
reducers: {
init: (state) => {
@ -126,6 +131,25 @@ const LoginSlice = createSlice({
];
}
},
addDirectMessage: (state, action) => {
let n = action.payload;
if (!Array.isArray(n)) {
n = [n];
}
let didChange = false;
for (let x of n) {
if (!state.dms.some(a => a.id === x.id)) {
state.dms.push(x);
didChange = true;
}
}
if (didChange) {
state.dms = [
...state.dms
];
}
},
logout: (state) => {
window.localStorage.removeItem(PrivateKeyItem);
window.localStorage.removeItem(PublicKeyItem);
@ -136,6 +160,7 @@ const LoginSlice = createSlice({
state.notifications = [];
state.loggedOut = true;
state.readNotifications = 0;
state.dms = [];
},
markNotificationsRead: (state) => {
state.readNotifications = new Date().getTime();
@ -144,5 +169,5 @@ const LoginSlice = createSlice({
}
});
export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, logout, markNotificationsRead } = LoginSlice.actions;
export const { init, setPrivateKey, setPublicKey, setRelays, removeRelay, setFollows, addNotifications, addDirectMessage, logout, markNotificationsRead } = LoginSlice.actions;
export const reducer = LoginSlice.reducer;

View File

@ -1594,6 +1594,11 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
"@protobufjs/base64@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
"@reduxjs/toolkit@^1.9.1":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.1.tgz#4c34dc4ddcec161535288c60da5c19c3ef15180e"