mirror of
https://github.com/styppo/hamstr.git
synced 2024-10-18 05:23:28 +00:00
Implement DMs
This commit is contained in:
parent
946e5a098a
commit
58b774ec77
@ -3,8 +3,11 @@
|
|||||||
v-model="text"
|
v-model="text"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
:rows="rows"
|
||||||
@input="resize"
|
@input="resize"
|
||||||
@focus="resize"
|
@focus="resize"
|
||||||
|
@keydown.exact.enter="onEnterPressed"
|
||||||
|
@keydown.ctrl.enter="onCtrlEnterPressed"
|
||||||
ref="textarea"
|
ref="textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
</template>
|
</template>
|
||||||
@ -32,9 +35,17 @@ export default {
|
|||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
submitOnEnter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue', 'submit'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
text: this.modelValue,
|
text: this.modelValue,
|
||||||
@ -79,6 +90,18 @@ export default {
|
|||||||
focus() {
|
focus() {
|
||||||
this.$refs.textarea.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() {
|
mounted() {
|
||||||
if (this.text) {
|
if (this.text) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!count" class="list-placeholder">
|
<div v-if="!count" class="list-placeholder">
|
||||||
<q-spinner v-if="loading" size="sm" />
|
<q-spinner v-if="loading" size="sm" />
|
||||||
<p v-else>Nothing here</p>
|
<p v-else>{{ label }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -16,6 +16,10 @@ export default {
|
|||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Nothing here',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
:icon="route.name.toLowerCase()"
|
:icon="route.name.toLowerCase()"
|
||||||
:to="route.path"
|
:to="route.path"
|
||||||
:enabled="route.enabled !== false"
|
:enabled="route.enabled !== false"
|
||||||
|
:indicator="route.indicator && route.indicator()"
|
||||||
@click="$emit('mobile-menu-close')"
|
@click="$emit('mobile-menu-close')"
|
||||||
>
|
>
|
||||||
{{ route.name }}
|
{{ route.name }}
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
:icon="icon"
|
:icon="icon"
|
||||||
:icon-color="iconColor"
|
:icon-color="iconColor"
|
||||||
/>
|
/>
|
||||||
|
<q-badge v-if="indicator" floating rounded color="primary" class="indicator" />
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item-content">
|
<div class="menu-item-content">
|
||||||
<slot />
|
<slot />
|
||||||
@ -42,6 +43,10 @@ export default {
|
|||||||
enabled: {
|
enabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['click'],
|
emits: ['click'],
|
||||||
@ -69,10 +74,17 @@ a {
|
|||||||
&-logo {
|
&-logo {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
position: relative;
|
||||||
svg {
|
svg {
|
||||||
transition: 20ms ease-in-out fill;
|
transition: 20ms ease-in-out fill;
|
||||||
fill: #fff;
|
fill: #fff;
|
||||||
}
|
}
|
||||||
|
.indicator {
|
||||||
|
padding: 5px;
|
||||||
|
min-height: 10px;
|
||||||
|
top: -1px;
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&-content {
|
&-content {
|
||||||
transition: 20ms ease-in-out color;
|
transition: 20ms ease-in-out color;
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import {useMessageStore} from 'src/nostr/store/MessageStore'
|
||||||
|
import {useAppStore} from 'stores/App'
|
||||||
|
|
||||||
export const MENU_ITEMS = [
|
export const MENU_ITEMS = [
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
@ -16,6 +19,7 @@ export const MENU_ITEMS = [
|
|||||||
name: 'Messages',
|
name: 'Messages',
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
signInRequired: true,
|
signInRequired: true,
|
||||||
|
indicator: () => useMessageStore().getNumUnread(useAppStore().myPubkey) > 0
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: 'Settings',
|
// name: 'Settings',
|
||||||
|
132
src/components/Message/ConversationItem.vue
Normal file
132
src/components/Message/ConversationItem.vue
Normal 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>
|
76
src/components/Message/EncryptedMessage.vue
Normal file
76
src/components/Message/EncryptedMessage.vue
Normal 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>
|
191
src/components/Message/MessageEditor.vue
Normal file
191
src/components/Message/MessageEditor.vue
Normal 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>
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-header">
|
<div class="page-header" :class="{dense}">
|
||||||
<div
|
<div
|
||||||
v-if="backButton"
|
v-if="backButton"
|
||||||
class="back-button"
|
class="back-button"
|
||||||
@ -50,6 +50,10 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
dense: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
titleFromRoute() {
|
titleFromRoute() {
|
||||||
@ -80,7 +84,7 @@ export default defineComponent({
|
|||||||
.back-button {
|
.back-button {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
margin-right: 20px;
|
margin-right: 1rem;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -110,6 +114,11 @@ export default defineComponent({
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
&.dense {
|
||||||
|
.back-button {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $phone) {
|
@media screen and (max-width: $phone) {
|
||||||
|
@ -22,7 +22,6 @@
|
|||||||
<SearchBox />
|
<SearchBox />
|
||||||
<WelcomeBox />
|
<WelcomeBox />
|
||||||
<FollowingBox />
|
<FollowingBox />
|
||||||
<!-- <Trends />-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -46,7 +45,6 @@ import {useQuasar} from 'quasar'
|
|||||||
import MainMenu from 'components/MainMenu/MainMenu.vue'
|
import MainMenu from 'components/MainMenu/MainMenu.vue'
|
||||||
import SearchBox from 'components/SearchBox/SearchBox.vue'
|
import SearchBox from 'components/SearchBox/SearchBox.vue'
|
||||||
import WelcomeBox from 'components/Sidebar/WelcomeBox.vue'
|
import WelcomeBox from 'components/Sidebar/WelcomeBox.vue'
|
||||||
// import Trends from 'components/Trends/index.vue'
|
|
||||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||||
import SignInDialog from 'components/SignIn/SignInDialog.vue'
|
import SignInDialog from 'components/SignIn/SignInDialog.vue'
|
||||||
import CreatePostDialog from 'components/CreatePost/CreatePostDialog.vue'
|
import CreatePostDialog from 'components/CreatePost/CreatePostDialog.vue'
|
||||||
@ -59,7 +57,6 @@ export default defineComponent({
|
|||||||
MainMenu,
|
MainMenu,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
WelcomeBox,
|
WelcomeBox,
|
||||||
// Trends,
|
|
||||||
BaseIcon,
|
BaseIcon,
|
||||||
SignInDialog,
|
SignInDialog,
|
||||||
CreatePostDialog,
|
CreatePostDialog,
|
||||||
@ -142,6 +139,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
.layout-flow {
|
.layout-flow {
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
|
max-width: calc(100% - 80px)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,6 +162,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
&-flow {
|
&-flow {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
&-sidebar {
|
&-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -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'
|
import Nip07 from 'src/utils/Nip07'
|
||||||
|
|
||||||
export class Account {
|
export class Account {
|
||||||
@ -30,4 +30,32 @@ export class Account {
|
|||||||
}
|
}
|
||||||
return event
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
createdAt(timestamp) {
|
||||||
this.event.created_at = timestamp
|
this.event.created_at = timestamp
|
||||||
return this
|
return this
|
||||||
|
@ -12,6 +12,7 @@ import {Observable} from 'src/nostr/utils'
|
|||||||
import {CloseAfter} from 'src/nostr/Relay'
|
import {CloseAfter} from 'src/nostr/Relay'
|
||||||
import DateUtils from 'src/utils/DateUtils'
|
import DateUtils from 'src/utils/DateUtils'
|
||||||
import {useAppStore} from 'stores/App'
|
import {useAppStore} from 'stores/App'
|
||||||
|
import {useMessageStore} from 'src/nostr/store/MessageStore'
|
||||||
|
|
||||||
class Stream extends Observable {
|
class Stream extends Observable {
|
||||||
constructor(sub) {
|
constructor(sub) {
|
||||||
@ -118,8 +119,7 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
case EventKind.CONTACT:
|
case EventKind.CONTACT:
|
||||||
return useContactStore().addEvent(event)
|
return useContactStore().addEvent(event)
|
||||||
case EventKind.DM:
|
case EventKind.DM:
|
||||||
// TODO
|
return useMessageStore().addEvent(event)
|
||||||
break
|
|
||||||
case EventKind.DELETE:
|
case EventKind.DELETE:
|
||||||
// TODO metadata, contacts?
|
// TODO metadata, contacts?
|
||||||
useNoteStore().deleteEvent(event)
|
useNoteStore().deleteEvent(event)
|
||||||
@ -139,8 +139,7 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
publish(event) {
|
publish(event) {
|
||||||
// FIXME
|
// FIXME represent 'local' somehow
|
||||||
console.log('publishing', event)
|
|
||||||
this.addEvent(event, {url: '<local>'})
|
this.addEvent(event, {url: '<local>'})
|
||||||
return this.client.publish(event)
|
return this.client.publish(event)
|
||||||
},
|
},
|
||||||
@ -163,7 +162,7 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
|
|
||||||
// Subscribe to events created by us.
|
// Subscribe to events created by us.
|
||||||
const subMeta = this.client.subscribe({
|
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],
|
authors: [pubkey],
|
||||||
limit: 0,
|
limit: 0,
|
||||||
}, `user:${pubkey}`)
|
}, `user:${pubkey}`)
|
||||||
@ -172,15 +171,11 @@ export const useNostrStore = defineStore('nostr', {
|
|||||||
|
|
||||||
// Subscribe to events tagging us
|
// Subscribe to events tagging us
|
||||||
const subTags = this.client.subscribe({
|
const subTags = this.client.subscribe({
|
||||||
kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE],
|
kinds: [EventKind.NOTE, EventKind.REACTION, EventKind.SHARE, EventKind.DM],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
limit: 100,
|
limit: 500,
|
||||||
}, `notifications:${pubkey}`)
|
}, `notifications:${pubkey}`)
|
||||||
subTags.on('event', event => {
|
subTags.on('event', this.addEvent.bind(this))
|
||||||
console.log('got notificaiton', event)
|
|
||||||
// this.addEvent.bind(this)
|
|
||||||
this.addEvent(event)
|
|
||||||
})
|
|
||||||
subs.push(subTags)
|
subs.push(subTags)
|
||||||
|
|
||||||
this.userSubs = subs
|
this.userSubs = subs
|
||||||
|
38
src/nostr/model/Message.js
Normal file
38
src/nostr/model/Message.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
107
src/nostr/store/MessageStore.js
Normal file
107
src/nostr/store/MessageStore.js
Normal 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
|
||||||
|
})
|
@ -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>
|
|
131
src/pages/messages/Conversation.vue
Normal file
131
src/pages/messages/Conversation.vue
Normal 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>
|
58
src/pages/messages/Messages.vue
Normal file
58
src/pages/messages/Messages.vue
Normal 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>
|
@ -30,9 +30,14 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
component: () => import('pages/Messages.vue'),
|
component: () => import('pages/messages/Messages.vue'),
|
||||||
name: 'messages',
|
name: 'messages',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/messages/:pubkey(npub[a-z0-9A-Z]{59})',
|
||||||
|
component: () => import('pages/messages/Conversation.vue'),
|
||||||
|
name: 'conversation',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
component: () => import('pages/Settings.vue'),
|
component: () => import('pages/Settings.vue'),
|
||||||
|
@ -53,6 +53,16 @@ export const useAppStore = defineStore('app', {
|
|||||||
if (!await this.signInIfNeeded()) return
|
if (!await this.signInIfNeeded()) return
|
||||||
if (!this.activeAccount.canSign() && !await this.signIn('private-key')) return
|
if (!this.activeAccount.canSign() && !await this.signIn('private-key')) return
|
||||||
return this.activeAccount.sign(event)
|
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)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -16,4 +16,14 @@ export default class Nip07 {
|
|||||||
Nip07.enforceAvailable()
|
Nip07.enforceAvailable()
|
||||||
return window.nostr.signEvent(event)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user