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"
|
||||
: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) {
|
||||
|
@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 }}
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
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>
|
||||
<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) {
|
||||
|
@ -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;
|
||||
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
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',
|
||||
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'),
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user