Implement DMs

This commit is contained in:
styppo 2023-01-23 21:56:50 +00:00
parent 946e5a098a
commit 58b774ec77
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
21 changed files with 865 additions and 42 deletions

View File

@ -3,8 +3,11 @@
v-model="text"
:placeholder="placeholder"
:disabled="disabled"
:rows="rows"
@input="resize"
@focus="resize"
@keydown.exact.enter="onEnterPressed"
@keydown.ctrl.enter="onCtrlEnterPressed"
ref="textarea"
></textarea>
</template>
@ -32,9 +35,17 @@ export default {
disabled: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 2,
},
submitOnEnter: {
type: Boolean,
default: false,
}
},
emits: ['update:modelValue'],
emits: ['update:modelValue', 'submit'],
data() {
return {
text: this.modelValue,
@ -79,6 +90,18 @@ export default {
focus() {
this.$refs.textarea.focus()
},
onEnterPressed(e) {
if (this.submitOnEnter) {
e.preventDefault()
this.$emit('submit')
}
},
onCtrlEnterPressed(e) {
if (this.submitOnEnter) {
e.preventDefault()
this.insertText('\n')
}
},
},
mounted() {
if (this.text) {

View File

@ -1,7 +1,7 @@
<template>
<div v-if="!count" class="list-placeholder">
<q-spinner v-if="loading" size="sm" />
<p v-else>Nothing here</p>
<p v-else>{{ label }}</p>
</div>
</template>
@ -16,6 +16,10 @@ export default {
loading: {
type: Boolean,
default: false,
},
label: {
type: String,
default: 'Nothing here',
}
}
}

View File

@ -12,6 +12,7 @@
:icon="route.name.toLowerCase()"
:to="route.path"
:enabled="route.enabled !== false"
:indicator="route.indicator && route.indicator()"
@click="$emit('mobile-menu-close')"
>
{{ route.name }}

View File

@ -10,6 +10,7 @@
:icon="icon"
:icon-color="iconColor"
/>
<q-badge v-if="indicator" floating rounded color="primary" class="indicator" />
</div>
<div class="menu-item-content">
<slot />
@ -42,6 +43,10 @@ export default {
enabled: {
type: Boolean,
default: true
},
indicator: {
type: Boolean,
default: false,
}
},
emits: ['click'],
@ -69,10 +74,17 @@ a {
&-logo {
width: 2rem;
height: 2rem;
position: relative;
svg {
transition: 20ms ease-in-out fill;
fill: #fff;
}
.indicator {
padding: 5px;
min-height: 10px;
top: -1px;
right: -1px;
}
}
&-content {
transition: 20ms ease-in-out color;

View File

@ -1,3 +1,6 @@
import {useMessageStore} from 'src/nostr/store/MessageStore'
import {useAppStore} from 'stores/App'
export const MENU_ITEMS = [
{
name: 'Home',
@ -16,6 +19,7 @@ export const MENU_ITEMS = [
name: 'Messages',
path: '/messages',
signInRequired: true,
indicator: () => useMessageStore().getNumUnread(useAppStore().myPubkey) > 0
},
// {
// name: 'Settings',

View File

@ -0,0 +1,132 @@
<template>
<div class="conversation-item" :class="{'has-unread': conversation.numUnread}" @click="goToConversation()">
<div class="conversation-item-avatar">
<UserAvatar :pubkey="conversation.pubkey" />
</div>
<div class="conversation-item-content">
<div class="username">
<UserName :pubkey="conversation.pubkey" show-verified />
</div>
<div v-if="conversation.latestMessage" class="message">
<EncryptedMessage :message="conversation.latestMessage" />
</div>
</div>
<div class="conversation-item-info">
<div v-if="conversation.latestMessage" class="created-at">
{{ createdAt }}
</div>
<q-badge
v-if="conversation.numUnread"
:label="conversation.numUnread"
color="primary"
class="unreads"
rounded
/>
</div>
</div>
</template>
<script>
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import EncryptedMessage from 'components/Message/EncryptedMessage.vue'
import DateUtils from 'src/utils/DateUtils'
import {hexToBech32} from 'src/utils/utils'
export default {
name: 'ConversationItem',
components: {EncryptedMessage, UserName, UserAvatar},
props: {
conversation: {
type: Object,
required: true,
},
},
computed: {
createdAt() {
if (!this.conversation.latestMessage) return
const format = this.$q.screen.lt.md ? 'short' : 'long'
return DateUtils.formatFromNow(this.conversation.latestMessage.createdAt, format)
}
},
methods: {
goToConversation() {
this.$router.push({
name: 'conversation',
params: {
pubkey: hexToBech32(this.conversation.pubkey, 'npub')
}
})
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
@import "assets/variables.scss";
.conversation-item {
display: flex;
padding: 1rem;
cursor: pointer;
transition: 120ms ease;
&:hover {
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
}
&-avatar {
margin-right: 12px;
}
&-content {
flex-grow: 1;
max-width: calc(100% - 160px);
.message {
font-size: 0.95em;
max-height: 2rem;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&-info {
text-align: right;
flex-grow: 1;
.created-at {
color: $color-light-gray;
font-size: .95em;
}
.unreads {
padding: 4px 6px;
min-width: 20px;
font-weight: bold;
text-align: center;
}
}
&.has-unread {
.created-at {
color: $color-primary;
}
}
& + .conversation-item {
border-top: $border-dark;
}
}
@media screen and (max-width: $phone-lg) {
.conversation-item-content {
max-width: calc(100% - 90px);
}
}
</style>
<style lang="scss">
@import "assets/variables.scss";
.conversation-item-content {
.message p {
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<PostRenderer v-if="note?.content" :note="note" />
<span v-else-if="!decryptFailed" class="click-to-decrypt" @click="decrypt">Click to decrypt</span>
<span v-else class="decrypt-failed" @click="decrypt">Decryption failed</span>
</template>
<script>
import {useAppStore} from 'stores/App'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import Note from 'src/nostr/model/Note'
export default {
name: 'EncryptedMessage',
components: {PostRenderer},
props: {
message: {
type: Object,
required: true,
},
sent: {
type: Boolean,
default: false,
},
},
setup() {
return {
app: useAppStore(),
}
},
data() {
return {
plaintext: null,
decryptFailed: false,
}
},
computed: {
note() {
if (!this.message) return
const note = new Note(this.message.id, this.message)
note.content = this.message.plaintext || this.plaintext
return note
}
},
methods: {
async decrypt() {
try {
const counterparty = this.sent ? this.message.recipient : this.message.author
this.plaintext = await this.app.decryptMessage(counterparty, this.message.content)
this.message.cachePlaintext(this.plaintext)
} catch (e) {
console.error('Failed to decrypt message', e)
this.decryptFailed = true
}
}
},
async mounted() {
if (!this.message.plaintext && this.app.activeAccount.canDecrypt()) {
await this.decrypt()
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
.click-to-decrypt {
cursor: pointer;
font-size: 0.9rem;
}
.decrypt-failed {
cursor: pointer;
font-size: 0.9rem;
color: $negative;
}
</style>

View File

@ -0,0 +1,191 @@
<template>
<div class="message-editor">
<div class="input-section" @click="$refs.textarea.focus()">
<AutoSizeTextarea
v-model="content"
ref="textarea"
:placeholder="placeholder"
:disabled="publishing"
:rows="1"
@submit="publishMessage"
submit-on-enter
/>
<div class="inline-controls">
<div class="inline-controls-item">
<BaseIcon icon="emoji" />
<q-menu ref="menuEmojiPicker">
<EmojiPicker @select="onEmojiSelected"/>
</q-menu>
</div>
</div>
</div>
<div class="controls">
<div class="controls-submit">
<q-btn
icon="send"
:loading="publishing"
:disable="!hasContent()"
color="primary"
@click="publishMessage"
round
/>
</div>
</div>
</div>
</template>
<script>
import BaseIcon from 'components/BaseIcon/index.vue'
import EmojiPicker from 'components/CreatePost/EmojiPicker.vue'
import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import EventBuilder from 'src/nostr/EventBuilder'
export default {
name: 'MessageEditor',
components: {
AutoSizeTextarea,
BaseIcon,
EmojiPicker,
},
props: {
recipient: {
type: String,
required: true,
},
placeholder: {
type: String,
default: 'Message',
},
autofocus: {
type: Boolean,
default: false,
}
},
emits: ['publish'],
data() {
return {
content: '',
publishing: false,
}
},
setup() {
return {
app: useAppStore(),
nostr: useNostrStore(),
}
},
methods: {
hasContent() {
return this.content.trim().length > 0
},
onEmojiSelected(emoji) {
if (emoji.native) {
this.$refs.menuEmojiPicker.hide()
this.$refs.textarea.insertText(emoji.native)
}
},
focus() {
this.$refs.textarea.focus()
},
reset() {
this.content = ''
},
async publishMessage() {
this.publishing = true
try {
const ciphertext = await this.app.encryptMessage(this.recipient, this.content)
if (!ciphertext) return
const event = EventBuilder.message(this.app.myPubkey, this.recipient, ciphertext).build()
if (!await this.app.signEvent(event)) return
this.nostr.publish(event)
this.reset()
this.$nextTick(this.focus.bind(this))
this.$emit('publish', event)
} catch (e) {
console.error('Failed to send message', e)
this.$q.notify({
message: `Failed to send message`,
color: 'negative'
})
}
this.publishing = false
},
},
mounted() {
if (this.autofocus) {
this.$nextTick(this.focus.bind(this))
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
.message-editor {
display: flex;
align-items: flex-end;
padding: 0 1rem;
width: 100%;
.input-section {
width: 100%;
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
border-radius: 1rem;
position: relative;
padding: 12px 36px 12px 1rem;
margin-right: .5rem;
textarea {
display: block;
width: 100%;
max-height: 10rem;
line-height: 18px;
padding: 0;
overflow: hidden;
background-color: transparent;
color: #fff;
appearance: none;
-webkit-appearance: none;
resize: none;
border: none;
&:focus {
border: none;
outline: none;
}
}
}
.inline-controls {
position: absolute;
right: 4px;
bottom: 5px;
&-item {
width: 32px;
height: 32px;
border-radius: 999px;
cursor:pointer;
padding: 5px;
svg {
width: 100%;
fill: $color-primary
}
&:hover {
background-color: rgba($color: $color-primary, $alpha: 0.3);
}
}
}
.controls {
}
}
</style>
<style lang="scss">
@import "assets/theme/colors.scss";
.message-editor {
.controls .controls-submit i.q-icon {
margin-left: 4px;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="page-header">
<div class="page-header" :class="{dense}">
<div
v-if="backButton"
class="back-button"
@ -50,6 +50,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
dense: {
type: Boolean,
default: false,
}
},
methods: {
titleFromRoute() {
@ -80,7 +84,7 @@ export default defineComponent({
.back-button {
width: 2.5rem;
height: 2.5rem;
margin-right: 20px;
margin-right: 1rem;
padding: 6px;
border-radius: 999px;
cursor: pointer;
@ -110,6 +114,11 @@ export default defineComponent({
flex-grow: 1;
text-align: right;
}
&.dense {
.back-button {
margin-right: .5rem;
}
}
}
@media screen and (max-width: $phone) {

View File

@ -22,7 +22,6 @@
<SearchBox />
<WelcomeBox />
<FollowingBox />
<!-- <Trends />-->
</div>
</div>
@ -46,7 +45,6 @@ import {useQuasar} from 'quasar'
import MainMenu from 'components/MainMenu/MainMenu.vue'
import SearchBox from 'components/SearchBox/SearchBox.vue'
import WelcomeBox from 'components/Sidebar/WelcomeBox.vue'
// import Trends from 'components/Trends/index.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
import SignInDialog from 'components/SignIn/SignInDialog.vue'
import CreatePostDialog from 'components/CreatePost/CreatePostDialog.vue'
@ -59,7 +57,6 @@ export default defineComponent({
MainMenu,
SearchBox,
WelcomeBox,
// Trends,
BaseIcon,
SignInDialog,
CreatePostDialog,
@ -142,6 +139,7 @@ export default defineComponent({
}
.layout-flow {
min-width: unset;
max-width: calc(100% - 80px)
}
}
@ -164,6 +162,7 @@ export default defineComponent({
}
&-flow {
border: 0;
max-width: 100%;
}
&-sidebar {
display: none;

View File

@ -1,4 +1,4 @@
import {getEventHash, getPublicKey, signEvent} from 'nostr-tools'
import {getEventHash, getPublicKey, signEvent, nip04} from 'nostr-tools'
import Nip07 from 'src/utils/Nip07'
export class Account {
@ -30,4 +30,32 @@ export class Account {
}
return event
}
canDecrypt() {
return this.canSign()
}
decrypt(pubkey, content) {
if (this.privkey) {
return nip04.decrypt(this.privkey, pubkey, content)
} else if (this.useExtension && Nip07.isAvailable()) {
return Nip07.decrypt(pubkey, content)
} else {
throw new Error('cannot decrypt')
}
}
canEncrypt() {
return this.canSign()
}
encrypt(pubkey, content) {
if (this.privkey) {
return nip04.encrypt(this.privkey, pubkey, content)
} else if (this.useExtension && Nip07.isAvailable()) {
return Nip07.encrypt(pubkey, content)
} else {
throw new Error('cannot encrypt')
}
}
}

View File

@ -84,6 +84,16 @@ export default class EventBuilder {
})
}
static message(author, recipient, ciphertext) {
const tags = [[TagType.PUBKEY, recipient]]
return new EventBuilder({
kind: EventKind.DM,
pubkey: author,
content: ciphertext,
tags,
})
}
createdAt(timestamp) {
this.event.created_at = timestamp
return this

View File

@ -12,6 +12,7 @@ import {Observable} from 'src/nostr/utils'
import {CloseAfter} from 'src/nostr/Relay'
import DateUtils from 'src/utils/DateUtils'
import {useAppStore} from 'stores/App'
import {useMessageStore} from 'src/nostr/store/MessageStore'
class Stream extends Observable {
constructor(sub) {
@ -118,8 +119,7 @@ export const useNostrStore = defineStore('nostr', {
case EventKind.CONTACT:
return useContactStore().addEvent(event)
case EventKind.DM:
// TODO
break
return useMessageStore().addEvent(event)
case EventKind.DELETE:
// TODO metadata, contacts?
useNoteStore().deleteEvent(event)
@ -139,8 +139,7 @@ export const useNostrStore = defineStore('nostr', {
},
publish(event) {
// FIXME
console.log('publishing', event)
// FIXME represent 'local' somehow
this.addEvent(event, {url: '<local>'})
return this.client.publish(event)
},
@ -163,7 +162,7 @@ export const useNostrStore = defineStore('nostr', {
// Subscribe to events created by us.
const subMeta = this.client.subscribe({
kinds: [EventKind.METADATA, EventKind.CONTACT, EventKind.REACTION, EventKind.SHARE],
kinds: [EventKind.METADATA, EventKind.CONTACT, EventKind.REACTION, EventKind.SHARE, EventKind.DM],
authors: [pubkey],
limit: 0,
}, `user:${pubkey}`)
@ -172,15 +171,11 @@ export const useNostrStore = defineStore('nostr', {
// Subscribe to events tagging us
const subTags = this.client.subscribe({
kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE],
kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE, EventKind.DM],
'#p': [pubkey],
limit: 100,
limit: 500,
}, `notifications:${pubkey}`)
subTags.on('event', event => {
console.log('got notificaiton', event)
// this.addEvent.bind(this)
this.addEvent(event)
})
subTags.on('event', this.addEvent.bind(this))
subs.push(subTags)
this.userSubs = subs

View File

@ -0,0 +1,38 @@
import {EventKind} from 'src/nostr/model/Event'
export default class Message {
constructor(id, args) {
this.id = id
this.author = args.author || args.pubkey
this.createdAt = args.createdAt
this.content = args.content || ''
this.tags = args.tags
this.recipients = args.recipients
this.ancestor = args.ancestor
this.plaintext = args.plaintext
}
static from(event) {
console.assert(event.kind === EventKind.DM)
const recipients = event.pubkeyRefs()
if (!recipients || !recipients.length) return
const ancestor = event.eventRefs().ancestor()
return new Message(event.id, {
author: event.pubkey,
createdAt: event.createdAt,
content: event.content,
tags: event.tags,
recipients,
ancestor,
})
}
get recipient() {
return this.recipients[0]
}
cachePlaintext(plaintext) {
this.plaintext = plaintext
}
}

View File

@ -0,0 +1,107 @@
import {defineStore} from 'pinia'
import Message from 'src/nostr/model/Message'
import {NoteOrder} from 'src/nostr/store/NoteStore'
import DateUtils from 'src/utils/DateUtils'
export const useMessageStore = defineStore('message', {
state: () => ({
messages: {}, // id -> message
byRecipient: {}, // recipient -> sender -> [messages]
bySender: {}, // sender -> recipient -> [messages]
}),
getters: {
getConversations(state) {
return pubkey => {
const conversations = []
const counterparties = new Set()
Object.keys(state.byRecipient[pubkey] || {}).forEach(pubkey => counterparties.add(pubkey))
Object.keys(state.bySender[pubkey] || {}).forEach(pubkey => counterparties.add(pubkey))
for (const counterparty of counterparties) {
const messages = this.getMessages(pubkey, counterparty)
const latestMessage = messages.reduce((a, b) => a.createdAt > b.createdAt ? a : b, {createdAt: 0})
const lastRead = useMessageStatusStore().getLastRead(pubkey, counterparty)
const numUnread = messages.filter(msg => msg.createdAt > lastRead).length
conversations.push({
pubkey: counterparty,
latestMessage,
numUnread,
})
}
conversations.sort((a, b) => b.latestMessage?.createdAt - a.latestMessage?.createdAt)
return conversations
}
},
getConversation() {
// TODO Take e-tags into account for sorting
return (pubkey, counterparty) => this
.getMessages(pubkey, counterparty)
.sort(NoteOrder.CREATION_DATE_ASC)
},
getMessages(state) {
return (pubkey, counterparty) => (state.byRecipient[pubkey]?.[counterparty] || [])
.concat(pubkey !== counterparty
? state.bySender[pubkey]?.[counterparty] || []
: []
)
},
getNumUnread() {
// TODO improve performance
return pubkey => this.getConversations(pubkey).reduce((sum, conv) => sum + conv.numUnread, 0)
},
},
actions: {
addEvent(event) {
const message = Message.from(event)
if (!message) return false
if (this.messages[message.id]) return
this.messages[message.id] = message
if (!this.bySender[message.author]) {
this.bySender[message.author] = {}
}
const byRecipient = this.bySender[message.author]
for (const recipient of message.recipients) {
if (!byRecipient[recipient]) {
byRecipient[recipient] = []
}
byRecipient[recipient].push(message)
if (!this.byRecipient[recipient]) {
this.byRecipient[recipient] = {}
}
const bySender = this.byRecipient[recipient]
if (!bySender[message.author]) {
bySender[message.author] = []
}
bySender[message.author].push(message)
}
return message
},
markAsRead(pubkey, counterparty) {
return useMessageStatusStore().markAsRead(pubkey, counterparty)
},
}
})
const useMessageStatusStore = defineStore('message-status', {
state: () => ({
lastRead: {} // recipient -> sender -> lastReadTimestamp
}),
getters: {
getLastRead(state) {
return (pubkey, counterparty) => state.lastRead[pubkey]?.[counterparty] || 0
}
},
actions: {
markAsRead(pubkey, counterparty) {
if (!this.lastRead[pubkey]) {
this.lastRead[pubkey] = {}
}
this.lastRead[pubkey][counterparty] = DateUtils.now()
},
},
persist: true
})

View File

@ -1,20 +0,0 @@
<template>
<PageHeader back-button />
<h3>Under construction</h3>
</template>
<script>
import PageHeader from 'components/PageHeader.vue'
export default {
name: 'Messages',
components: {PageHeader}
}
</script>
<style scoped>
h3 {
margin: 0;
padding: 0 1rem;
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<PageHeader back-button dense>
<UserCard :pubkey="counterparty" class="conversation-header" clickable />
</PageHeader>
<div class="conversation">
<div class="pusher"></div>
<q-chat-message
v-for="message in conversation"
:key="message.id"
:sent="message.author === app.myPubkey"
:stamp="formatMessageDate(message.createdAt)"
>
<EncryptedMessage :message="message" :sent="message.author === app.myPubkey" />
</q-chat-message>
<p v-if="!conversation?.length" class="placeholder">
This is the beginning of your message history with <UserName :pubkey="counterparty" clickable />.
</p>
</div>
<div class="conversation-reply">
<MessageEditor :recipient="counterparty" :placeholder="placeholder" @publish="onPublish" autofocus />
</div>
</template>
<script>
import PageHeader from 'components/PageHeader.vue'
import UserCard from 'components/User/UserCard.vue'
import EncryptedMessage from 'components/Message/EncryptedMessage.vue'
import MessageEditor from 'components/Message/MessageEditor.vue'
import {useMessageStore} from 'src/nostr/store/MessageStore'
import {useAppStore} from 'stores/App'
import DateUtils from 'src/utils/DateUtils'
import {bech32ToHex} from 'src/utils/utils'
import UserName from 'components/User/UserName.vue'
export default {
name: 'Conversation',
components: {UserName, MessageEditor, EncryptedMessage, PageHeader, UserCard},
setup() {
return {
app: useAppStore(),
messages: useMessageStore(),
}
},
computed: {
counterparty() {
return bech32ToHex(this.$route.params.pubkey)
},
conversation() {
if (!this.app.isSignedIn) return
return this.messages.getConversation(this.app.myPubkey, this.counterparty)
},
placeholder() {
// TODO i18n
return this.app.myPubkey === this.counterparty
? 'Jot something down'
: 'Message'
},
},
methods: {
formatMessageDate(timestamp) {
return DateUtils.formatFromNowLong(timestamp)
},
onPublish() {
this.markAsRead()
this.$nextTick(() => window.scrollTo(0, document.body.scrollHeight))
},
markAsRead() {
if (this.app.isSignedIn && this.counterparty) {
this.messages.markAsRead(this.app.myPubkey, this.counterparty)
}
},
},
mounted() {
this.markAsRead()
}
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
@import "assets/variables.scss";
.conversation-header {
margin: 1px 0;
}
.conversation {
display: flex;
flex-direction: column;
padding: 1rem 2rem 80px;
min-height: calc(100vh - 78px);
.pusher {
flex-grow: 1;
}
.placeholder {
text-align: center;
}
}
.conversation-reply {
background: linear-gradient(to bottom, rgba($color: $color-bg, $alpha: 0), rgba($color: $color-bg, $alpha: 1) 6%);
position: fixed;
bottom: 0;
z-index: 600;
max-width: 658px;
width: 100%;
padding: 6px 0 26px;
}
@media screen and (max-width: $tablet) {
.conversation-reply {
padding-bottom: 22px;
}
}
@media screen and (max-width: $phone-lg) {
.conversation-reply {
width: calc(100% - 80px);
}
}
@media screen and (max-width: $phone) {
.conversation-reply {
width: 100%;
padding-bottom: 1rem;
}
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<PageHeader back-button />
<div class="messages">
<ConversationItem v-for="conversation in conversations" :key="conversation.pubkey" :conversation="conversation" />
<p v-if="!conversations?.length">To send a message, click on the <BaseIcon icon="messages" /> icon in the recipient's profile.</p>
</div>
</template>
<script>
import PageHeader from 'components/PageHeader.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useMessageStore} from 'src/nostr/store/MessageStore'
import ConversationItem from 'components/Message/ConversationItem.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
export default {
name: 'Messages',
components: {
BaseIcon,
ConversationItem,
PageHeader,
},
setup() {
return {
app: useAppStore(),
nostr: useNostrStore(),
messages: useMessageStore(),
}
},
computed: {
conversations() {
if (!this.app.isSignedIn) return []
return this.messages.getConversations(this.app.myPubkey)
},
},
}
</script>
<style lang="scss" scoped>
@import "assets/theme/colors.scss";
h3 {
margin: 0;
padding: 0 1rem;
}
p {
padding: 0 1rem;
svg {
display: inline-block;
width: 20px;
height: 20px;
fill: $color-fg;
vertical-align: bottom;
}
}
</style>

View File

@ -30,9 +30,14 @@ const routes = [
},
{
path: '/messages',
component: () => import('pages/Messages.vue'),
component: () => import('pages/messages/Messages.vue'),
name: 'messages',
},
{
path: '/messages/:pubkey(npub[a-z0-9A-Z]{59})',
component: () => import('pages/messages/Conversation.vue'),
name: 'conversation',
},
{
path: '/settings',
component: () => import('pages/Settings.vue'),

View File

@ -53,6 +53,16 @@ export const useAppStore = defineStore('app', {
if (!await this.signInIfNeeded()) return
if (!this.activeAccount.canSign() && !await this.signIn('private-key')) return
return this.activeAccount.sign(event)
}
},
async decryptMessage(pubkey, content) {
if (!await this.signInIfNeeded()) return
if (!this.activeAccount.canDecrypt() && !await this.signIn('private-key')) return
return this.activeAccount.decrypt(pubkey, content)
},
async encryptMessage(pubkey, content) {
if (!await this.signInIfNeeded()) return
if (!this.activeAccount.canEncrypt() && !await this.signIn('private-key')) return
return this.activeAccount.encrypt(pubkey, content)
},
},
})

View File

@ -16,4 +16,14 @@ export default class Nip07 {
Nip07.enforceAvailable()
return window.nostr.signEvent(event)
}
static encrypt(pubkey, plaintext) {
Nip07.enforceAvailable()
return window.nostr.nip04.encrypt(pubkey, plaintext)
}
static decrypt(pubkey, ciphertext) {
Nip07.enforceAvailable()
return window.nostr.nip04.decrypt(pubkey, ciphertext)
}
}