feat(i18n): add spanish translations

This commit is contained in:
Fernando López Guevara 2023-01-31 15:30:15 -03:00
parent 6513463cd2
commit b389d3feb9
41 changed files with 886 additions and 553 deletions

13
.vscode/settings.json vendored
View File

@ -3,13 +3,6 @@
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [
"source.fixAll.eslint"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
]
}
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"]
}

View File

@ -15,6 +15,7 @@
"bech32-buffer": "^0.2.1",
"core-js": "^3.6.5",
"cross-fetch": "^3.1.5",
"date-fns": "^2.29.3",
"emoji-mart-vue-fast": "^12.0.1",
"jdenticon": "^3.2.0",
"light-bolt11-decoder": "^2.1.0",
@ -23,7 +24,6 @@
"markdown-it-emoji": "^2.0.2",
"markdown-it-sub": "^1.0.0",
"markdown-it-sup": "^1.0.0",
"moment": "^2.29.4",
"nostr-tools": "^1.1.1",
"pinia": "^2.0.11",
"pinia-plugin-persistedstate": "^3.0.2",

View File

@ -2,13 +2,20 @@ import { boot } from 'quasar/wrappers'
import { createI18n } from 'vue-i18n'
import messages from 'src/i18n'
export default boot(({ app }) => {
const i18n = createI18n({
locale: 'en-US',
globalInjection: true,
messages
})
const [lng = 'en'] = (navigator?.language || '').split('-')
const i18n = createI18n({
locale: lng,
fallbackLocale: 'en',
globalInjection: true,
messages,
})
export default boot(({ app }) => {
// Set i18n instance on app
app.use(i18n)
})
const $t = i18n.global.t
export { $t }

View File

@ -2,7 +2,7 @@
<div ref="button" class="async-load-button">
<q-btn
:loading="loading"
:label="noMore ? labelNoMore : label"
:label="$t(noMore ? labelNoMore : label)"
@click="load"
size="md"
flat
@ -23,16 +23,16 @@ export default defineComponent({
},
label: {
type: String,
default: 'Load more'
default: 'Load more',
},
labelNoMore: {
type: String,
default: 'No more items. Try again?'
default: 'No more items. Try again?',
},
autoload: {
type: Boolean,
default: false,
}
},
},
data() {
return {
@ -53,7 +53,7 @@ export default defineComponent({
this.loading = false
this.$emit('loaded', result)
}
},
},
mounted() {
if (this.autoload) {
@ -66,7 +66,7 @@ export default defineComponent({
},
unmounted() {
if (this.observer) this.observer.disconnect()
}
},
})
</script>

View File

@ -2,9 +2,9 @@
<div ref="link" class="async-load-link" @click="load">
<q-spinner v-if="loading" size="sm" />
<span v-else>
{{ noMore ? prefixNoMore : (!hasItems ? prefix : '') }}
{{ $t(noMore ? prefixNoMore : !hasItems ? prefix : "") }}
<a>
{{ noMore ? labelNoMore : label }}
{{ $t(noMore ? labelNoMore : label) }}
</a>
</span>
</div>
@ -23,19 +23,19 @@ export default defineComponent({
},
label: {
type: String,
default: 'Load more'
default: 'Load more',
},
labelNoMore: {
type: String,
default: 'Try again?'
default: 'Try again?',
},
prefix: {
type: String,
default: 'Nothing here.'
default: 'Nothing here.',
},
prefixNoMore: {
type: String,
default: 'Nothing found.'
default: 'Nothing found.',
},
hasItems: {
type: Boolean,
@ -44,7 +44,7 @@ export default defineComponent({
autoload: {
type: Boolean,
default: false,
}
},
},
data() {
return {
@ -65,7 +65,7 @@ export default defineComponent({
this.loading = false
this.$emit('loaded', result)
}
},
},
mounted() {
if (this.autoload) {
@ -78,7 +78,7 @@ export default defineComponent({
},
unmounted() {
if (this.observer) this.observer.disconnect()
}
},
})
</script>

View File

@ -1,7 +1,7 @@
<template>
<textarea
v-model="text"
:placeholder="placeholder"
:placeholder="$t(placeholder)"
:disabled="disabled"
:rows="rows"
@input="resize"
@ -30,7 +30,7 @@ export default {
},
placeholder: {
type: String,
default: 'What\'s happening?',
default: "What's happening?",
},
disabled: {
type: Boolean,
@ -43,7 +43,7 @@ export default {
submitOnEnter: {
type: Boolean,
default: false,
}
},
},
emits: ['update:modelValue', 'submit'],
data() {
@ -78,7 +78,11 @@ export default {
},
insertText(text) {
const textarea = this.$refs.textarea
textarea.setRangeText(text, textarea.selectionStart, textarea.selectionEnd)
textarea.setRangeText(
text,
textarea.selectionStart,
textarea.selectionEnd
)
textarea.focus()
if (textarea.selectionStart === textarea.selectionEnd) {
@ -107,10 +111,9 @@ export default {
if (this.text) {
this.resize()
}
}
},
}
</script>
<style scoped>
</style>

View File

@ -18,19 +18,19 @@ import 'emoji-mart-vue-fast/css/emoji-mart.css'
export default {
name: 'EmojiPicker',
components: {
Picker
Picker,
},
emits: ['select'],
emits: ['select'],
data() {
return {
emojiIndex: new EmojiIndex(emojiData)
emojiIndex: new EmojiIndex(emojiData),
}
},
methods: {
onSelect(emoji) {
this.$emit('select', emoji)
}
}
},
},
}
</script>

View File

@ -1,8 +1,5 @@
<template>
<div
class="post-editor"
:class="{compact, collapsed, connector}"
>
<div class="post-editor" :class="{ compact, collapsed, connector }">
<div class="post-editor-author">
<div v-if="connector" class="connector-top">
<div class="connector-line"></div>
@ -24,7 +21,7 @@
<div class="controls-media-item">
<BaseIcon icon="emoji" />
<q-menu ref="menuEmojiPicker">
<EmojiPicker @select="onEmojiSelected"/>
<EmojiPicker @select="onEmojiSelected" />
</q-menu>
</div>
<div class="controls-media-item disabled">
@ -32,15 +29,19 @@
</div>
</div>
<div class="controls-submit">
<button :disabled="!hasContent() || publishing" @click="publishPost" class="btn btn-primary btn-sm">
<button
:disabled="!hasContent() || publishing"
@click="publishPost"
class="btn btn-primary btn-sm"
>
<q-spinner v-if="publishing" />
<span v-else>Post</span>
<span v-else>{{ $t("Post") }}</span>
</button>
</div>
</div>
</div>
<div class="post-editor-fake-submit" v-if="collapsed">
<button class="btn btn-primary btn-sm" disabled>Post</button>
<button class="btn btn-primary btn-sm" disabled>{{ $t("Post") }}</button>
</div>
</div>
</template>
@ -50,9 +51,10 @@ import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
import EmojiPicker from 'components/CreatePost/EmojiPicker.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'PostEditor',
@ -69,7 +71,7 @@ export default {
},
placeholder: {
type: String,
default: 'What\'s happening?',
default: "What's happening?",
},
compact: {
type: Boolean,
@ -82,7 +84,7 @@ export default {
expanded: {
type: Boolean,
default: false,
}
},
},
emits: ['publish'],
data() {
@ -116,25 +118,30 @@ export default {
this.publishing = true
const event = this.ancestor
? EventBuilder.reply(this.ancestor, this.app.myPubkey, this.content).build()
? EventBuilder.reply(
this.ancestor,
this.app.myPubkey,
this.content
).build()
: EventBuilder.post(this.app.myPubkey, this.content).build()
if (!await this.app.signEvent(event)) return
if (!(await this.app.signEvent(event))) return
const numRelays = await this.nostr.publish(event)
if (numRelays) {
this.reset()
this.$emit('publish', event)
// TODO i18n
const postType = this.ancestor ? 'Reply' : 'Post'
this.$q.notify({
message: `${postType} published to ${numRelays} relays`,
message: $t(`${postType} published to {numRelays} relays`, {
numRelays,
}),
color: 'positive',
})
} else {
this.$q.notify({
message: `Failed to publish post`,
color: 'negative'
message: $t(`Failed to publish post`),
color: 'negative',
})
}
@ -204,11 +211,11 @@ export default {
width: 32px;
height: 32px;
border-radius: 999px;
cursor:pointer;
cursor: pointer;
padding: 5px;
svg {
width: 100%;
fill: $color-primary
fill: $color-primary;
}
&:hover {
background-color: rgba($color: $color-primary, $alpha: 0.3);

View File

@ -1,22 +1,23 @@
<template>
<div class="feed">
<div class="load-more-container" :class="{'more-available': numUnreads}">
<div class="load-more-container" :class="{ 'more-available': numUnreads }">
<AsyncLoadButton
v-if="numUnreads"
:load-fn="loadNewer"
:label="`Load ${numUnreads} unread`"
:label="$t('Load {unread} unread', { unread: numUnreads })"
/>
</div>
<Thread v-for="thread in visible" :key="thread[0].id" :thread="thread" class="full-width" />
<Thread
v-for="thread in visible"
:key="thread[0].id"
:thread="thread"
class="full-width"
/>
<ListPlaceholder :count="visible.length" :loading="loading" />
<AsyncLoadButton
v-if="visible.length"
:load-fn="loadOlder"
autoload
/>
<AsyncLoadButton v-if="visible.length" :load-fn="loadOlder" autoload />
</div>
</template>
@ -24,8 +25,8 @@
import AsyncLoadButton from 'components/AsyncLoadButton.vue'
import Thread from 'components/Post/Thread.vue'
import ListPlaceholder from 'components/ListPlaceholder.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import DateUtils from 'src/utils/DateUtils'
import Bots from 'src/utils/bots'
@ -39,13 +40,13 @@ export default {
components: {
ListPlaceholder,
Thread,
AsyncLoadButton
AsyncLoadButton,
},
props: {
feed: {
type: Object,
required: true,
}
},
},
setup() {
return {
@ -73,30 +74,31 @@ export default {
},
timestampOldest() {
return this.visible[this.visible.length - 1]?.[0]?.createdAt
}
},
},
methods: {
init() {
const filters = typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
this.stream = this.nostr.stream(
filters,
{
subId: `feed:${this.feed.name}`,
timeout: 3000,
}
)
this.stream.on('init', notes => {
const data = typeof this.feed.data === 'function'
? this.feed.data()
: this.feed.data || []
const filters =
typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
this.stream = this.nostr.stream(filters, {
subId: `feed:${this.feed.name}`,
timeout: 3000,
})
this.stream.on('init', (notes) => {
const data =
typeof this.feed.data === 'function'
? this.feed.data()
: this.feed.data || []
const items = notes
.concat(data)
.filter(note => this.filterNote(note, this.feed.hideBots))
.map(note => [note]) // TODO Single element thread
.filter((note) => this.filterNote(note, this.feed.hideBots))
.map((note) => [note]) // TODO Single element thread
.sort(feedOrder)
.filter((item, pos, array) => !pos || item[0].id !== array[pos - 1][0].id)
.filter(
(item, pos, array) => !pos || item[0].id !== array[pos - 1][0].id
)
this.visible = items.slice(0, MAX_ITEMS_VISIBLE)
this.loading = false
@ -104,14 +106,16 @@ export default {
this.$emit('load', this.feed)
// Wait a bit before showing the first unreads
setTimeout(() => this.recentlyLoaded = false, 5000)
setTimeout(() => (this.recentlyLoaded = false), 5000)
})
this.stream.on('update', note => {
this.stream.on('update', (note) => {
if (!this.filterNote(note, this.feed.hideBots)) return
if (note.createdAt >= this.timestampNewest) {
this.newer.push([note]) // TODO Single element thread
} else if (note.createdAt >= this.timestampOldest) {
const idx = this.visible.findIndex(thread => thread[0].createdAt >= note.createdAt)
const idx = this.visible.findIndex(
(thread) => thread[0].createdAt >= note.createdAt
)
this.visible.splice(idx, 0, [note]) // TODO Single element thread
}
})
@ -138,17 +142,18 @@ export default {
// Wait a bit before showing unreads again
this.recentlyLoaded = true
setTimeout(() => this.recentlyLoaded = false, 5000)
setTimeout(() => (this.recentlyLoaded = false), 5000)
return true
},
async loadOlder() {
const feedFilters = typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
const feedFilters =
typeof this.feed.filters === 'function'
? this.feed.filters()
: this.feed.filters
const until = this.timestampOldest || DateUtils.now()
const filters = Object.assign({}, feedFilters, {until})
const filters = Object.assign({}, feedFilters, { until })
if (this.older.length >= filters.limit) {
const chunk = this.older.splice(0, filters.limit)
@ -159,11 +164,13 @@ export default {
// Remove any residual older items
this.older = []
const older = await this.nostr.fetch(filters, {subId: `feed:${this.feed.name}-older`})
const older = await this.nostr.fetch(filters, {
subId: `feed:${this.feed.name}-older`,
})
const items = older
.filter(note => note.createdAt <= until)
.filter(note => this.filterNote(note, this.feed.hideBots))
.map(note => [note]) // TODO Single element thread
.filter((note) => note.createdAt <= until)
.filter((note) => this.filterNote(note, this.feed.hideBots))
.map((note) => [note]) // TODO Single element thread
.sort(feedOrder)
// TODO Deduplicate feed items
@ -183,7 +190,7 @@ export default {
},
unmounted() {
if (this.stream) this.stream.close()
}
},
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<div v-if="!count" class="list-placeholder">
<q-spinner v-if="loading" size="sm" />
<p v-else>{{ label }}</p>
<p v-else>{{ $t(label) }}</p>
</div>
</template>
@ -19,9 +19,9 @@ export default {
},
label: {
type: String,
default: 'Nothing here',
}
}
default: 'Nothing here.',
},
},
}
</script>

View File

@ -8,14 +8,16 @@
</div>
<div v-for="(route, i) in items" :key="i">
<MenuItem
v-if="!hideItemsRequiringSignIn || !route.signInRequired || app.isSignedIn"
v-if="
!hideItemsRequiringSignIn || !route.signInRequired || app.isSignedIn
"
:icon="route.name.toLowerCase()"
:to="route.path"
:enabled="route.enabled !== false"
:indicator="route.indicator && route.indicator()"
@click="$emit('mobile-menu-close')"
>
{{ route.name }}
{{ $t(route.name) }}
</MenuItem>
</div>
<MenuItem
@ -25,14 +27,14 @@
:enabled="app.isSignedIn"
@click="$emit('mobile-menu-close')"
>
Profile
{{ $t("Profile") }}
</MenuItem>
<MenuItem
icon="settings"
to="/settings"
@click="$emit('mobile-menu-close')"
>
Settings
{{ $t("Settings") }}
</MenuItem>
<div
@ -40,7 +42,7 @@
class="menu-post-button"
@click="createPost"
>
<span class="label">Post</span>
<span class="label">{{ $t("Post") }}</span>
<BaseIcon class="icon" icon="pen" />
</div>
</div>
@ -49,18 +51,15 @@
<ProfilePopup v-if="app.isSignedIn" />
<div v-else class="sign-in" @click="signIn">
<q-icon class="icon" name="login" size="sm" />
<div class="label">Log in</div>
<div class="label">{{ $t("Log in") }}</div>
</div>
</div>
<div
class="mobile-close-menu-button"
@click="$emit('mobile-menu-close')"
>
<div class="mobile-close-menu-button" @click="$emit('mobile-menu-close')">
<div class="icon">
<BaseIcon icon="left" />
</div>
<span>Close</span>
<span>{{ $t("Close") }}</span>
</div>
</menu>
</template>
@ -70,9 +69,9 @@ import MenuItem from 'components/MainMenu/MenuItem.vue'
import BaseIcon from 'components/BaseIcon'
import ProfilePopup from 'components/MainMenu/ProfilePopup'
import Logo from 'components/Logo.vue'
import {useAppStore} from 'stores/App'
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
import {hexToBech32} from 'src/utils/utils'
import { useAppStore } from 'stores/App'
import { MENU_ITEMS } from 'components/MainMenu/constants.js'
import { hexToBech32 } from 'src/utils/utils'
export default {
name: 'MainMenu',
@ -80,13 +79,13 @@ export default {
Logo,
MenuItem,
BaseIcon,
ProfilePopup
ProfilePopup,
},
props: {
hideItemsRequiringSignIn: {
type: Boolean,
default: false,
}
},
},
emits: ['mobile-menu-close'],
data() {
@ -108,8 +107,8 @@ export default {
this.$emit('mobile-menu-close')
this.app.signIn()
},
hexToBech32
}
hexToBech32,
},
}
</script>
@ -134,7 +133,8 @@ menu {
}
&-logo {
margin: 1rem 0;
svg, img {
svg,
img {
display: block;
width: 50px;
height: 50px;

View File

@ -9,7 +9,7 @@
<base-icon :icon="item.icon" />
</div>
<div class="content">
{{ item.name }}
{{ $t(item.name) }}
</div>
</div>
</div>

View File

@ -15,11 +15,17 @@
</div>
</div>
</div>
<q-menu :offset="[0, 20]" target=".menu-profile" class="menu-profile-popup" >
<q-menu :offset="[0, 20]" target=".menu-profile" class="menu-profile-popup">
<div>
<div v-for="(_, pk) in settings.accounts" :key="pk" class="popup-header" @click="app.switchAccount(pk)" v-close-popup>
<div
v-for="(_, pk) in settings.accounts"
:key="pk"
class="popup-header"
@click="app.switchAccount(pk)"
v-close-popup
>
<div class="sidebar-profile-pic">
<UserAvatar :pubkey="pk" :clickable="false"/>
<UserAvatar :pubkey="pk" :clickable="false" />
</div>
<div class="menu-profile-items">
<div class="profile-info">
@ -32,18 +38,21 @@
</div>
</div>
</div>
<hr class="popup-spacing">
<hr class="popup-spacing" />
<div class="popup-body">
<div class="popup-body-item" @click="app.signIn()" v-close-popup>
<p>Add an account</p>
<p>{{ $t("Add an account") }}</p>
</div>
<hr class="popup-spacing">
<hr class="popup-spacing" />
<div
class="popup-body-item"
@click="$refs.logout.show()"
v-close-popup
>
<p>Logout from <span><UserName :pubkey="pubkey" /></span></p>
<p>
{{ $t("Logout from") }}
<span><UserName :pubkey="pubkey" /></span>
</p>
</div>
</div>
</div>
@ -57,8 +66,8 @@ import BaseIcon from 'components/BaseIcon/index.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import LogoutDialog from 'components/User/LogoutDialog.vue'
import {useAppStore} from 'stores/App'
import {useSettingsStore} from 'stores/Settings'
import { useAppStore } from 'stores/App'
import { useSettingsStore } from 'stores/Settings'
export default {
name: 'ProfilePopup',
@ -66,7 +75,7 @@ export default {
LogoutDialog,
UserName,
UserAvatar,
BaseIcon
BaseIcon,
},
setup() {
return {
@ -80,7 +89,7 @@ export default {
},
accounts() {
return this.settings.accounts
}
},
},
}
</script>
@ -94,7 +103,7 @@ export default {
align-items: center;
margin-bottom: 1rem;
margin-right: 1rem;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 999px;
transition: 120ms ease-in-out;
@ -207,10 +216,9 @@ export default {
}
}
@media screen and (max-width: $phone) {
.menu-profile {
padding: .5rem;
padding: 0.5rem;
margin: 0 auto 1rem auto;
&-wrapper {
padding: 0 1rem;

View File

@ -1,17 +1,25 @@
<template>
<PostRenderer v-if="note" :note="note" />
<span v-else-if="!decryptFailed" class="click-to-decrypt" @click="clickToDecrypt && decrypt()">Click to decrypt</span>
<span v-else class="decrypt-failed" @click="decrypt">Decryption failed</span>
<span
v-else-if="!decryptFailed"
class="click-to-decrypt"
@click="clickToDecrypt && decrypt()"
>
{{ $t("Click to decrypt") }}
</span>
<span v-else class="decrypt-failed" @click="decrypt">
{{ $t("Decryption failed") }}
</span>
</template>
<script>
import {useAppStore} from 'stores/App'
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},
components: { PostRenderer },
props: {
message: {
type: Object,
@ -20,7 +28,7 @@ export default {
clickToDecrypt: {
type: Boolean,
default: false,
}
},
},
setup() {
return {
@ -38,17 +46,21 @@ export default {
const note = new Note(this.message.id, this.message)
note.content = this.message.plaintext
return note
}
},
},
methods: {
async decrypt() {
if (this.message.plaintext) return
try {
const messageId = this.message.id
const counterparty = this.message.author === this.app.myPubkey
? this.message.recipient
: this.message.author
const plaintext = await this.app.decryptMessage(counterparty, this.message.content)
const counterparty =
this.message.author === this.app.myPubkey
? this.message.recipient
: this.message.author
const plaintext = await this.app.decryptMessage(
counterparty,
this.message.content
)
// The message can change while we are decrypting it, so we need to make sure not to cache the wrong message.
if (this.message.id === messageId) {
this.message.cachePlaintext(plaintext)
@ -57,7 +69,7 @@ export default {
console.error('Failed to decrypt message', e)
this.decryptFailed = true
}
}
},
},
async mounted() {
if (this.app.activeAccount.canDecrypt()) {
@ -69,8 +81,8 @@ export default {
if (this.app.activeAccount.canDecrypt()) {
await this.decrypt()
}
}
}
},
},
}
</script>

View File

@ -14,7 +14,7 @@
<div class="inline-controls-item">
<BaseIcon icon="emoji" />
<q-menu ref="menuEmojiPicker">
<EmojiPicker @select="onEmojiSelected"/>
<EmojiPicker @select="onEmojiSelected" />
</q-menu>
</div>
</div>
@ -38,9 +38,10 @@
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 { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'MessageEditor',
@ -61,7 +62,7 @@ export default {
autofocus: {
type: Boolean,
default: false,
}
},
},
emits: ['publish'],
data() {
@ -95,10 +96,17 @@ export default {
async publishMessage() {
this.publishing = true
const ciphertext = await this.app.encryptMessage(this.recipient, this.content)
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
const event = EventBuilder.message(
this.app.myPubkey,
this.recipient,
ciphertext
).build()
if (!(await this.app.signEvent(event))) return
if (await this.nostr.publish(event)) {
this.reset()
@ -106,8 +114,8 @@ export default {
this.$emit('publish', event)
} else {
this.$q.notify({
message: `Failed to send message`,
color: 'negative'
message: $t(`Failed to send message`),
color: 'negative',
})
}
@ -118,7 +126,7 @@ export default {
if (this.autofocus) {
this.$nextTick(this.focus.bind(this))
}
}
},
}
</script>
@ -136,7 +144,7 @@ export default {
border-radius: 1rem;
position: relative;
padding: 12px 36px 12px 1rem;
margin-right: .5rem;
margin-right: 0.5rem;
textarea {
display: block;
width: 100%;
@ -164,11 +172,11 @@ export default {
width: 32px;
height: 32px;
border-radius: 999px;
cursor:pointer;
cursor: pointer;
padding: 5px;
svg {
width: 100%;
fill: $color-primary
fill: $color-primary;
}
&:hover {
background-color: rgba($color: $color-primary, $alpha: 0.3);

View File

@ -1,15 +1,11 @@
<template>
<div class="page-header" :class="{dense}">
<div
v-if="backButton"
class="back-button"
@click="$router.go(-1)"
>
<div class="page-header" :class="{ dense }">
<div v-if="backButton" class="back-button" @click="$router.go(-1)">
<base-icon icon="back" />
</div>
<div :class="{'profile-info': !!subline}">
<div :class="{ 'profile-info': !!subline }">
<slot>
<h2>{{ title || titleFromRoute() || 'Home' }}</h2>
<h2>{{ $t(title || titleFromRoute() || "Home") }}</h2>
<span v-if="subline">{{ subline }}</span>
</slot>
</div>
@ -31,7 +27,7 @@ export default defineComponent({
name: 'PageHeader',
components: {
Logo,
BaseIcon
BaseIcon,
},
props: {
title: {
@ -53,14 +49,14 @@ export default defineComponent({
dense: {
type: Boolean,
default: false,
}
},
},
methods: {
titleFromRoute() {
const route = this.$route.name?.toLowerCase()
return route?.charAt(0).toUpperCase() + route?.substring(1)
}
}
},
},
})
</script>
@ -116,14 +112,14 @@ export default defineComponent({
}
&.dense {
.back-button {
margin-right: .5rem;
margin-right: 0.5rem;
}
}
}
@media screen and (max-width: $phone) {
.page-header {
padding: .4rem 1rem;
padding: 0.4rem 1rem;
.logo {
display: block;
position: absolute;

View File

@ -14,7 +14,7 @@
<div class="post-content">
<div class="post-content-header">
<p v-if="note.hasAncestor()" class="in-reply-to">
Replying to
{{ $t("Replying to") }}
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
</a>
@ -32,7 +32,11 @@
<span>{{ formatDate(note.createdAt) }}</span>
</p>
<div class="post-content-actions">
<PostActions :note="note" flavor="hero" @comment="$refs.editor.focus()" />
<PostActions
:note="note"
flavor="hero"
@comment="$refs.editor.focus()"
/>
</div>
</div>
</div>
@ -41,7 +45,7 @@
:ancestor="note"
ref="editor"
compact
placeholder="Post your reply"
:placeholder="$t('Post your reply')"
/>
</div>
</div>
@ -52,8 +56,8 @@ import UserName from 'components/User/UserName.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import routerMixin from 'src/router/mixin'
import DateUtils from 'src/utils/DateUtils'
import PostActions from 'components/Post/PostActions.vue'
@ -71,7 +75,7 @@ export default {
props: {
note: {
type: Object,
required: true
required: true,
},
connector: {
type: Boolean,
@ -94,7 +98,7 @@ export default {
methods: {
formatDate: DateUtils.formatDate,
formatTime: DateUtils.formatTime,
}
},
}
</script>
@ -169,10 +173,10 @@ export default {
}
@media screen and (max-width: $phone) {
.post{
.post {
&-content {
&-header {
span{
span {
display: none;
}
.created-at {

View File

@ -1,7 +1,7 @@
<template>
<div
class="post"
:class="{clickable}"
:class="{ clickable }"
@click.stop="clickable && goToThread(note.id)"
>
<div class="post-author">
@ -21,7 +21,7 @@
<span class="created-at">{{ createdAt }}</span>
</p>
<p v-if="note.hasAncestor()" class="in-reply-to">
Replying to
{{ $t("Replying to") }}
<a @click.stop="goToProfile(ancestor?.author)">
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
</a>
@ -42,8 +42,8 @@ import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import PostActions from 'components/Post/PostActions.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import routerMixin from 'src/router/mixin'
import DateUtils from 'src/utils/DateUtils'
@ -59,7 +59,7 @@ export default {
props: {
note: {
type: Object,
required: true
required: true,
},
connectorTop: {
type: Boolean,
@ -112,14 +112,18 @@ export default {
},
},
mounted() {
const updateInterval = DateUtils.now() - this.note.createdAt >= 3600 // 1h
? 3600 // 1h
: 60 // 1m
this.refreshTimer = setInterval(() => this.refreshCounter++, updateInterval * 1000)
const updateInterval =
DateUtils.now() - this.note.createdAt >= 3600 // 1h
? 3600 // 1h
: 60 // 1m
this.refreshTimer = setInterval(
() => this.refreshCounter++,
updateInterval * 1000
)
},
unmounted() {
clearInterval(this.refreshTimer)
}
},
}
</script>
@ -161,7 +165,7 @@ export default {
}
&-content {
margin-left: 12px;
padding: 1rem 0 .4rem;
padding: 1rem 0 0.4rem;
flex-grow: 1;
max-width: 570px;
&-header {
@ -210,7 +214,7 @@ export default {
}
&-body {
color: #fff;
margin-bottom: .5rem;
margin-bottom: 0.5rem;
}
&-actions {
}
@ -222,7 +226,7 @@ export default {
&-content {
max-width: calc(100% - 48px - 1rem);
&-body {
margin-bottom: .5rem;
margin-bottom: 0.5rem;
}
}
}

View File

@ -2,29 +2,30 @@
<div class="post-actions" :class="flavor">
<div class="action-item comment" @click.stop="comment">
<BaseIcon icon="comment" />
<span>{{ stats.comments || '' }}</span>
<span>{{ stats.comments || "" }}</span>
</div>
<div class="action-item repost" @click.stop="repost">
<BaseIcon icon="repost" />
<span>{{ stats.shares || '' }}</span>
<span>{{ stats.shares || "" }}</span>
</div>
<div class="action-item like" :class="{active: liked}" @click.stop="like">
<div class="action-item like" :class="{ active: liked }" @click.stop="like">
<BaseIcon :icon="liked ? 'like_filled' : 'like'" />
<span>{{ stats.reactions || '' }}</span>
<span>{{ stats.reactions || "" }}</span>
</div>
</div>
</template>
<script>
import BaseIcon from 'components/BaseIcon/index.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useStatStore} from 'src/nostr/store/StatStore'
import {useAppStore} from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useStatStore } from 'src/nostr/store/StatStore'
import { useAppStore } from 'stores/App'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'PostActions',
components: {BaseIcon},
components: { BaseIcon },
emits: ['comment', 'repost'],
props: {
note: {
@ -34,13 +35,13 @@ export default {
flavor: {
type: String,
default: 'list',
}
},
},
setup() {
return {
app: useAppStore(),
stat: useStatStore(),
nostr: useNostrStore()
nostr: useNostrStore(),
}
},
computed: {
@ -57,7 +58,7 @@ export default {
methods: {
comment() {
if (this.flavor === 'list') {
this.app.createPost({ancestor: this.note.id})
this.app.createPost({ ancestor: this.note.id })
} else {
this.$emit('comment')
}
@ -75,21 +76,21 @@ export default {
},
async publishLike() {
const event = EventBuilder.reaction(this.note, this.app.myPubkey).build()
if (!await this.app.signEvent(event)) return
if (!await this.nostr.publish(event)) {
if (!(await this.app.signEvent(event))) return
if (!(await this.nostr.publish(event))) {
this.$q.notify({
message: 'Failed to publish reaction',
message: $t('Failed to publish reaction'),
color: 'negative',
})
}
},
async deleteLike() {
const ids = this.ourReactions.map(r => r.id)
const ids = this.ourReactions.map((r) => r.id)
const event = EventBuilder.delete(this.app.myPubkey, ids).build()
if (!await this.app.signEvent(event)) return
if (!await this.nostr.publish(event)) {
if (!(await this.app.signEvent(event))) return
if (!(await this.nostr.publish(event))) {
this.$q.notify({
message: 'Failed to delete reaction',
message: $t('Failed to delete reaction'),
color: 'negative',
})
}
@ -109,7 +110,7 @@ export default {
max-width: 490px;
width: 100%;
margin: auto;
padding: .5rem 0 .5rem 16px;
padding: 0.5rem 0 0.5rem 16px;
&.list {
width: calc(100% + 9px);
margin-left: -9px;
@ -140,7 +141,8 @@ export default {
span {
color: $color-light-gray;
}
&.active, &:hover {
&.active,
&:hover {
&.comment {
svg {
fill: $post-action-blue;

View File

@ -1,6 +1,6 @@
<template>
<div class="relative-position">
<div class="searchbox" :class="{focused}">
<div class="searchbox" :class="{ focused }">
<div class="searchbox-wrapper">
<div class="searchbox-icon">
<BaseIcon icon="search" />
@ -11,12 +11,12 @@
v-model="query"
ref="input"
type="text"
placeholder="Search profiles"
:placeholder="$t('Search profiles')"
@focus="toggleFocus"
@blur="toggleFocus"
@keyup="search"
@keyup.esc="$refs.input.blur()"
>
/>
</q-form>
</div>
</div>
@ -70,7 +70,10 @@ export default {
},
async search() {
if (this.query) {
this.results = (await this.provider.queryProfiles(this.query)).slice(0, 200)
this.results = (await this.provider.queryProfiles(this.query)).slice(
0,
200
)
} else {
this.results = []
}
@ -80,7 +83,7 @@ export default {
</script>
<style lang="scss">
@import 'assets/theme/colors.scss';
@import "assets/theme/colors.scss";
.searchbox {
height: 50px;
@ -126,9 +129,9 @@ export default {
max-height: 70vh;
overflow: hidden;
background-color: $color-bg;
border-radius: .5rem;
border-radius: 0.5rem;
z-index: 600;
margin-top: -.75rem;
margin-top: -0.75rem;
box-shadow: $shadow-white;
overflow-y: scroll;
scrollbar-color: transparent transparent;
@ -136,10 +139,12 @@ export default {
width: 0;
height: 0;
}
&::-webkit-scrollbar-thumb { /* Foreground */
&::-webkit-scrollbar-thumb {
/* Foreground */
background: $color-dark-gray;
}
&::-webkit-scrollbar-track { /* Background */
&::-webkit-scrollbar-track {
/* Background */
background: transparent;
}
&-item {
@ -152,7 +157,7 @@ export default {
}
.query-example {
color: $color-light-gray;
font-size: .95rem;
font-size: 0.95rem;
padding: 1rem;
}
}

View File

@ -1,32 +1,61 @@
<template>
<q-form v-if="app.isSignedIn" class="profile-settings" @submit.stop="updateProfile">
<h3>Profile</h3>
<q-form
v-if="app.isSignedIn"
class="profile-settings"
@submit.stop="updateProfile"
>
<h3>{{ $t("Profile") }}</h3>
<div class="input">
<q-input v-model="name" label="Name" maxlength="64" dense />
<q-input v-model="name" :label="$t('Name')" maxlength="64" dense />
</div>
<div class="input">
<q-input v-model="about" label="About" maxlength="150" autogrow dense />
<q-input
v-model="about"
:label="$t('About')"
maxlength="150"
autogrow
dense
/>
</div>
<div class="input">
<q-input v-model="picture" label="Picture URL" dense />
<img v-if="picture" :src="picture" class="picture-preview" loading="lazy" />
<q-input v-model="picture" :label="$t('Picture URL')" dense />
<img
v-if="picture"
:src="picture"
class="picture-preview"
loading="lazy"
/>
</div>
<div class="input">
<q-input v-model="nip05" label="NIP05 Identifier" dense />
<q-icon v-if="verified" name="verified" class="nip05-verified" size="sm" />
<q-input v-model="nip05" :label="$t('NIP05 Identifier')" dense />
<q-icon
v-if="verified"
name="verified"
class="nip05-verified"
size="sm"
/>
</div>
<div class="buttons">
<button type="submit" :disabled="!changed" class="btn btn-sm btn-primary">Save</button>
<button class="btn btn-sm" :disabled="!changed" @click="setDataFromProfile">Reset</button>
<button type="submit" :disabled="!changed" class="btn btn-sm btn-primary">
{{ $t("Save") }}
</button>
<button
class="btn btn-sm"
:disabled="!changed"
@click="setDataFromProfile"
>
{{ $t("Reset") }}
</button>
</div>
</q-form>
</template>
<script>
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import Nip05 from 'src/utils/Nip05'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'ProfileSettings',
@ -55,18 +84,20 @@ export default {
return this.nostr.getProfile(this.pubkey)
},
changed() {
return this.name !== (this.profile?.name || '')
|| this.about !== (this.profile?.about || '')
|| this.picture !== (this.profile?.picture || '')
|| this.nip05 !== (this.profile?.nip05?.url || '')
return (
this.name !== (this.profile?.name || '') ||
this.about !== (this.profile?.about || '') ||
this.picture !== (this.profile?.picture || '') ||
this.nip05 !== (this.profile?.nip05?.url || '')
)
},
},
methods: {
setDataFromProfile() {
this.name = (this.profile?.name || '')
this.about = (this.profile?.about || '')
this.picture = (this.profile?.picture || '')
this.nip05 = (this.profile?.nip05.url || '')
this.name = this.profile?.name || ''
this.about = this.profile?.about || ''
this.picture = this.profile?.picture || ''
this.nip05 = this.profile?.nip05.url || ''
this.verified = this.profile?.nip05.verified
},
async updateProfile() {
@ -77,11 +108,11 @@ export default {
nip05: this.nip05 || undefined,
}
const event = EventBuilder.metadata(this.pubkey, metadata).build()
if (!await this.app.signEvent(event)) return
if (!await this.nostr.publish(event)) {
if (!(await this.app.signEvent(event))) return
if (!(await this.nostr.publish(event))) {
this.$q.notify({
message: 'Failed to update profile',
color: 'negative'
message: $t('Failed to update profile'),
color: 'negative',
})
}
},
@ -92,11 +123,11 @@ export default {
},
async nip05() {
this.verified = await Nip05.verify(this.pubkey, this.nip05)
}
},
},
mounted() {
this.setDataFromProfile()
}
},
}
</script>
@ -134,7 +165,7 @@ export default {
font-weight: 600;
}
button + button {
margin-left: .5rem;
margin-left: 0.5rem;
}
}
}
@ -145,11 +176,12 @@ export default {
.profile-settings .input {
.q-field__label {
color: $color-light-gray;
margin: 0 .5rem;
margin: 0 0.5rem;
}
input, textarea {
input,
textarea {
color: #fff;
padding: 0 .5rem;
padding: 0 0.5rem;
font-weight: 500;
}
.q-field__control {

View File

@ -1,14 +1,32 @@
<template>
<div class="relay-settings">
<h3>Relays</h3>
<h3>{{ $t("Relays") }}</h3>
<div v-for="relay in settings.relays" :key="relay" class="relay">
<span class="relay-url">{{ relay }}</span>
<!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />-->
<q-btn icon="delete_outline" size="sm" class="btn-icon" flat round @click="removeRelay(relay)" />
<!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />-->
<q-btn
icon="delete_outline"
size="sm"
class="btn-icon"
flat
round
@click="removeRelay(relay)"
>
<q-tooltip>{{ $t("Delete relay") }}</q-tooltip>
</q-btn>
</div>
<q-form class="add-relay" @submit.stop="addRelay">
<q-input v-model="newRelayUrl" label="Add a relay" dense />
<q-btn type="submit" icon="add_circle_outline" size="sm" flat round class="btn-icon" />
<q-input v-model="newRelayUrl" :label="$t('Add relay')" dense />
<q-btn
type="submit"
icon="add_circle_outline"
size="sm"
flat
round
:disabled="!newRelayUrl"
class="btn-icon"
/>
</q-form>
<div class="buttons">
<button
@ -16,15 +34,15 @@
:disabled="!changed"
@click="settings.restoreDefaultRelays()"
>
Restore defaults
{{ $t("Restore defaults") }}
</button>
</div>
</div>
</template>
<script>
import {useSettingsStore} from 'stores/Settings'
import {Notify} from 'quasar'
import { useSettingsStore } from 'stores/Settings'
import { Notify } from 'quasar'
// import {useNostrStore} from 'src/nostr/NostrStore'
export default {
@ -43,7 +61,7 @@ export default {
computed: {
changed() {
return !this.settings.hasDefaultRelays()
}
},
},
methods: {
addRelay() {
@ -53,14 +71,14 @@ export default {
} catch (e) {
Notify.create({
message: 'Invalid URL',
color: 'negative'
color: 'negative',
})
return
}
if (url.protocol !== 'wss:') {
Notify.create({
message: 'Must be a wss:// URL',
color: 'negative'
color: 'negative',
})
return
}
@ -71,7 +89,7 @@ export default {
if (this.settings.hasRelay(href)) {
Notify.create({
message: 'Relay already exists',
color: 'negative'
color: 'negative',
})
return
}
@ -84,7 +102,7 @@ export default {
// isConnected(url) {
// return this.nostr.client.isConnectedTo(url)
// }
}
},
}
</script>
@ -100,7 +118,7 @@ export default {
.relay {
display: flex;
align-items: center;
padding: .5rem;
padding: 0.5rem;
border-bottom: $border-dark;
transition: 200ms ease;
&:hover {
@ -121,7 +139,7 @@ export default {
}
.btn-icon {
position: absolute;
right: .5rem;
right: 0.5rem;
top: 7px;
}
}
@ -136,7 +154,7 @@ export default {
font-weight: 600;
}
button + button {
margin-left: .5rem;
margin-left: 0.5rem;
}
}
}
@ -147,11 +165,11 @@ export default {
.relay-settings .add-relay {
.q-field__label {
color: $color-light-gray;
margin: 0 .5rem;
margin: 0 0.5rem;
}
input {
color: #fff;
padding: 0 .5rem;
padding: 0 0.5rem;
font-weight: 500;
}
.q-field__control {

View File

@ -2,11 +2,9 @@
<div v-if="app.isSignedIn && contacts?.length" class="following">
<div class="following-wrapper">
<div class="following-header">
<h3>Following</h3>
<h3>{{ $t("Following") }}</h3>
</div>
<div
class="following-body"
>
<div class="following-body">
<UserCard
v-for="contact in contacts"
:key="contact.pubkey"
@ -23,13 +21,13 @@
<script>
import UserCard from 'components/User/UserCard.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import routerMixin from 'src/router/mixin'
export default {
name: 'FollowingBox',
components: {UserCard},
components: { UserCard },
mixins: [routerMixin],
setup() {
return {
@ -43,7 +41,7 @@ export default {
},
contacts() {
return this.nostr.getContacts(this.pubkey)?.slice(0, 20)
}
},
},
}
</script>
@ -74,10 +72,12 @@ export default {
width: 0;
height: 0;
}
&::-webkit-scrollbar-thumb { /* Foreground */
&::-webkit-scrollbar-thumb {
/* Foreground */
background: $color-dark-gray;
}
&::-webkit-scrollbar-track { /* Background */
&::-webkit-scrollbar-track {
/* Background */
background: transparent;
}
&-item {
@ -104,7 +104,11 @@ export default {
right: 0;
height: 1.2rem;
border-radius: 0 0 1rem 1rem;
background: linear-gradient(180deg, rgba(29, 41, 53, 0), rgba(29, 41, 53, 1));
background: linear-gradient(
180deg,
rgba(29, 41, 53, 0),
rgba(29, 41, 53, 1)
);
}
}
</style>

View File

@ -1,23 +1,35 @@
<template>
<div class="welcome" v-if="!app.isSignedIn">
<div class="welcome-header">
<h3>New to Nostr?</h3>
<h3>{{ $t("New to Nostr?") }}</h3>
</div>
<div class="welcome-content">
<button v-if="nip07available" class="btn btn-primary" @click.stop="signInNip07()">Log in with Extension</button>
<button class="btn" :class="{'btn-primary': !nip07available}" @click.stop="signUp">
Create Account
<button
v-if="nip07available"
class="btn btn-primary"
@click.stop="signInNip07()"
>
{{ $t("Log in with Extension") }}
</button>
<button v-if="!nip07available" class="btn" @click.stop="signIn">Log in</button>
<a v-else @click.stop="signIn">Log in with key</a>
<button
class="btn"
:class="{ 'btn-primary': !nip07available }"
@click.stop="signUp"
>
{{ $t("Create Account") }}
</button>
<button v-if="!nip07available" class="btn" @click.stop="signIn">
{{ $t("Log in") }}
</button>
<a v-else @click.stop="signIn"> {{ $t("Log in with key") }}</a>
</div>
</div>
</template>
<script>
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useSettingsStore} from 'stores/Settings'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useSettingsStore } from 'stores/Settings'
import Nip07 from 'src/utils/Nip07'
export default {
@ -56,8 +68,8 @@ export default {
},
mounted() {
this.nip07available = Nip07.isAvailable()
setTimeout(() => this.nip07available = Nip07.isAvailable(), 300)
}
setTimeout(() => (this.nip07available = Nip07.isAvailable()), 300)
},
}
</script>
@ -82,7 +94,7 @@ export default {
text-align: center;
button {
width: 100%;
padding: .5rem;
padding: 0.5rem;
&:first-child {
margin-bottom: 1rem;
}
@ -98,5 +110,4 @@ export default {
}
}
}
</style>

View File

@ -1,5 +1,10 @@
<template>
<q-dialog v-model="app.signInDialog.open" @before-show="updateFragment" @hide="onClose" ref="signInDialog">
<q-dialog
v-model="app.signInDialog.open"
@before-show="updateFragment"
@hide="onClose"
ref="signInDialog"
>
<div class="sign-in-dialog">
<q-btn
v-if="showClose"
@ -29,17 +34,39 @@
<p class="prompt">
{{ prompt }}
</p>
<button v-if="nip07available" class="btn btn-primary" @click.stop="signInNip07()">Log in with Extension</button>
<button class="btn" :class="{'btn-primary': !nip07available}" @click.stop="fragment = 'sign-up'">
Create Account
<button
v-if="nip07available"
class="btn btn-primary"
@click.stop="signInNip07()"
>
{{ $t("Log in with Extension") }}
</button>
<button v-if="!nip07available" class="btn" @click.stop="fragment = 'sign-in'">Log in</button>
<a v-else @click.stop="fragment = 'sign-in'">Log in with key</a>
<button
class="btn"
:class="{ 'btn-primary': !nip07available }"
@click.stop="fragment = 'sign-up'"
>
{{ $t("Create Account") }}
</button>
<button
v-if="!nip07available"
class="btn"
@click.stop="fragment = 'sign-in'"
>
{{ $t("Log in") }}
</button>
<a v-else @click.stop="fragment = 'sign-in'">{{
$t("Log in with key")
}}</a>
</div>
<SignUpForm v-if="fragment === 'sign-up'" @complete="onComplete" />
<SignInForm v-if="fragment === 'sign-in'" @complete="onComplete"/>
<SignInForm v-if="fragment === 'private-key'" @complete="onComplete" private-key-only />
<SignInForm v-if="fragment === 'sign-in'" @complete="onComplete" />
<SignInForm
v-if="fragment === 'private-key'"
@complete="onComplete"
private-key-only
/>
</div>
</q-dialog>
</template>
@ -49,8 +76,8 @@ import Logo from 'components/Logo.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import SignUpForm from 'components/SignIn/SignUpForm.vue'
import SignInForm from 'components/SignIn/SignInForm.vue'
import {useAppStore} from 'stores/App'
import {useSettingsStore} from 'stores/Settings'
import { useAppStore } from 'stores/App'
import { useSettingsStore } from 'stores/Settings'
import Nip07 from 'src/utils/Nip07'
export default {
@ -59,13 +86,13 @@ export default {
Logo,
UserAvatar,
SignInForm,
SignUpForm
SignUpForm,
},
props: {
prompt: {
type: String,
default: ''
}
default: '',
},
},
setup() {
return {
@ -89,7 +116,7 @@ export default {
},
nip07available() {
return Nip07.isAvailable()
}
},
},
methods: {
onClose() {
@ -99,7 +126,7 @@ export default {
this.fragment = 'welcome'
this.pubkey = null
},
onComplete({pubkey}) {
onComplete({ pubkey }) {
this.pubkey = pubkey
this.$refs.signInDialog.hide()
},
@ -117,7 +144,7 @@ export default {
this.settings.addAccount(account)
this.app.switchAccount(pubkey)
this.onComplete({pubkey})
this.onComplete({ pubkey })
},
},
}
@ -137,8 +164,8 @@ export default {
position: absolute;
width: 16px;
height: 16px;
top: .5rem;
left: .5rem;
top: 0.5rem;
left: 0.5rem;
fill: #fff;
}
.logo {
@ -178,5 +205,4 @@ export default {
width: 100%;
}
}
</style>

View File

@ -1,28 +1,30 @@
<template>
<div class="sign-in">
<h3>{{ header }}</h3>
<h3>{{ $t(header) }}</h3>
<q-form @submit.stop="signIn">
<label for="private-key">{{ prompt }}</label>
<label for="private-key">{{ $t(prompt) }}</label>
<input
ref="input"
v-model="key"
:placeholder="placeholder"
:placeholder="$t(placeholder)"
maxlength="63"
:class="{
valid: validKey,
invalid: invalidKey,
}"
/>
<button type="submit" class="btn btn-primary" :disabled="!validKey">{{ buttonLabel }}</button>
<button type="submit" class="btn btn-primary" :disabled="!validKey">
{{ $t(buttonLabel) }}
</button>
</q-form>
</div>
</template>
<script>
import {decode as bech32decode} from 'bech32-buffer'
import {bech32prefix, bech32ToHex} from 'src/utils/utils'
import {useSettingsStore} from 'stores/Settings'
import {useAppStore} from 'stores/App'
import { decode as bech32decode } from 'bech32-buffer'
import { bech32prefix, bech32ToHex } from 'src/utils/utils'
import { useSettingsStore } from 'stores/Settings'
import { useAppStore } from 'stores/App'
export default {
name: 'SignInForm',
@ -31,7 +33,7 @@ export default {
privateKeyOnly: {
type: Boolean,
default: false,
}
},
},
data() {
return {
@ -40,44 +42,35 @@ export default {
},
computed: {
header() {
// TODO i18n
return this.privateKeyOnly
? 'Private key needed'
: 'Log in'
return this.privateKeyOnly ? 'Private key needed' : 'Log in'
},
prompt() {
// TODO i18n
return this.privateKeyOnly
? 'Paste your private key to continue'
: 'Paste your public or private key'
},
placeholder() {
// TODO i18n
return this.privateKeyOnly
? 'nsec…'
: 'npub… / nsec…'
return this.privateKeyOnly ? 'nsec…' : 'npub… / nsec…'
},
buttonLabel() {
// TODO i18n
return this.privateKeyOnly
? 'Continue'
: 'Log in'
return this.privateKeyOnly ? 'Continue' : 'Log in'
},
validKey() {
return this.isValidKey(this.key)
},
invalidKey() {
return this.key
&& this.key.length >= 63
&& !this.isValidKey(this.key)
}
return this.key && this.key.length >= 63 && !this.isValidKey(this.key)
},
},
methods: {
isValidKey(str) {
if (!str) return false
try {
const {data, prefix} = bech32decode(str.toLowerCase())
return data.byteLength === 32 && ((prefix === 'npub' && !this.privateKeyOnly) || prefix === 'nsec')
const { data, prefix } = bech32decode(str.toLowerCase())
return (
data.byteLength === 32 &&
((prefix === 'npub' && !this.privateKeyOnly) || prefix === 'nsec')
)
} catch (e) {
return false
}
@ -87,15 +80,15 @@ export default {
let opts
if (bech32prefix(this.key) === 'npub') {
opts = {pubkey: bech32ToHex(this.key)}
opts = { pubkey: bech32ToHex(this.key) }
} else {
opts = {privkey: bech32ToHex(this.key)}
opts = { privkey: bech32ToHex(this.key) }
}
const account = useSettingsStore().addAccount(opts)
useAppStore().switchAccount(account.pubkey)
this.$emit('complete', {pubkey: account.pubkey})
this.$emit('complete', { pubkey: account.pubkey })
},
},
mounted() {

View File

@ -1,20 +1,28 @@
<template>
<div class="sign-up">
<h3>Create Account</h3>
<h3>{{ $t("Create Account") }}</h3>
<q-form @submit.stop="signUp">
<label for="username">What's your name?</label>
<input v-model="username" ref="input" id="username" autocomplete="false" />
<button type="submit" class="btn btn-primary" :disabled="!validUsername">Create</button>
<label for="username">{{ $t("What's your name?") }}</label>
<input
v-model="username"
ref="input"
id="username"
autocomplete="false"
/>
<button type="submit" class="btn btn-primary" :disabled="!validUsername">
{{ $t("Create") }}
</button>
</q-form>
</div>
</template>
<script>
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {useSettingsStore} from 'stores/Settings'
import {generatePrivateKey} from 'nostr-tools'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useSettingsStore } from 'stores/Settings'
import { generatePrivateKey } from 'nostr-tools'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'SignUpForm',
@ -36,28 +44,30 @@ export default {
const privkey = generatePrivateKey()
const settings = useSettingsStore()
const account = settings.addAccount({privkey})
const account = settings.addAccount({ privkey })
const app = useAppStore()
app.switchAccount(account.pubkey)
const event = EventBuilder.metadata(account.pubkey, {name: this.username}).build()
const event = EventBuilder.metadata(account.pubkey, {
name: this.username,
}).build()
await app.signEvent(event)
if (await useNostrStore().publish(event)) {
this.$emit('complete', {
pubkey: account.pubkey,
name: this.username
name: this.username,
})
} else {
this.$q.notify({
message: 'Failed to create profile',
message: $t('Failed to create profile'),
color: 'negative',
})
}
}
},
},
mounted() {
this.$refs.input.focus()
}
},
}
</script>

View File

@ -2,17 +2,18 @@
<button
v-if="app.isSignedIn"
class="btn btn-sm"
:class="{'btn-primary': !isFollowing}"
:class="{ 'btn-primary': !isFollowing }"
@click="toggleFollow"
>
{{ isFollowing ? 'Unfollow' : 'Follow' }}
{{ $t(isFollowing ? "Unfollow" : "Follow") }}
</button>
</template>
<script>
import {useNostrStore} from 'src/nostr/NostrStore'
import {useAppStore} from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { useAppStore } from 'stores/App'
import EventBuilder from 'src/nostr/EventBuilder'
import { $t } from 'src/boot/i18n'
export default {
name: 'FollowButton',
@ -20,7 +21,7 @@ export default {
pubkey: {
type: String,
required: true,
}
},
},
setup() {
return {
@ -34,33 +35,39 @@ export default {
},
isFollowing() {
// TODO improve
return this.contacts?.length && this.contacts.find(contact => contact.pubkey === this.pubkey)
return (
this.contacts?.length &&
this.contacts.find((contact) => contact.pubkey === this.pubkey)
)
},
},
methods: {
async updateContacts(contacts) {
const event = EventBuilder.contacts(this.app.myPubkey, contacts.map(c => c.pubkey)).build()
if (!await this.app.signEvent(event)) return
if (!await this.nostr.publish(event)) {
const event = EventBuilder.contacts(
this.app.myPubkey,
contacts.map((c) => c.pubkey)
).build()
if (!(await this.app.signEvent(event))) return
if (!(await this.nostr.publish(event))) {
this.$q.notify({
message: 'Failed to update followers',
message: $t('Failed to update followers'),
color: 'negative',
})
}
},
toggleFollow() {
return this.isFollowing
? this.unfollow()
: this.follow()
return this.isFollowing ? this.unfollow() : this.follow()
},
async follow() {
const contacts = [].concat(this.contacts || []) // Clone array
contacts.push({pubkey: this.pubkey})
contacts.push({ pubkey: this.pubkey })
await this.updateContacts(contacts)
},
async unfollow() {
const contacts = [].concat(this.contacts || []) // Clone array
const idx = contacts.findIndex(contact => contact.pubkey === this.pubkey)
const idx = contacts.findIndex(
(contact) => contact.pubkey === this.pubkey
)
contacts.splice(idx, 1)
await this.updateContacts(contacts)
},
@ -69,5 +76,4 @@ export default {
</script>
<style scoped>
</style>

View File

@ -1,22 +1,31 @@
<template>
<q-dialog v-model="dialogOpen">
<div class="logout-dialog">
<q-btn icon="close" size="md" class="icon" flat round v-close-popup/>
<h3>Log out from <UserName :pubkey="pubkey" /></h3>
<p>
Do you really want to log out from <UserName :pubkey="pubkey" />?
</p>
<q-btn icon="close" size="md" class="icon" flat round v-close-popup />
<h3>
{{ $t("Do you really want to log out from") }}
<UserName :pubkey="pubkey" />?
</h3>
<p v-if="privateKey" class="warning">
<span class="warning-icon"><q-icon name="warning" size="lg" /></span>
<span class="warning-content">
Make sure you have a backup of your private key! Otherwise it is impossible to log back in to your account.
{{
$t(
"Make sure you have a backup of your private key! Otherwise it is impossible to log back in to your account."
)
}}
</span>
</p>
<input v-if="privateKey" :value="hexToBech32(privateKey, 'nsec')" readonly />
<input
v-if="privateKey"
:value="hexToBech32(privateKey, 'nsec')"
readonly
/>
<div class="buttons">
<button class="btn btn-sm btn-primary" @click="logout" v-close-popup>Log out</button>
<button class="btn btn-sm" v-close-popup>Cancel</button>
<button class="btn btn-sm btn-primary" @click="logout" v-close-popup>
{{ $t("Log out") }}
</button>
<button class="btn btn-sm" v-close-popup>{{ $t("Cancel") }}</button>
</div>
</div>
</q-dialog>
@ -24,14 +33,14 @@
<script>
import UserName from 'components/User/UserName.vue'
import {useAppStore} from 'stores/App'
import {useSettingsStore} from 'stores/Settings'
import {hexToBech32} from 'src/utils/utils'
import { useAppStore } from 'stores/App'
import { useSettingsStore } from 'stores/Settings'
import { hexToBech32 } from 'src/utils/utils'
export default {
name: 'LogoutDialog',
components: {
UserName
UserName,
},
props: {
pubkey: {
@ -51,7 +60,7 @@ export default {
computed: {
privateKey() {
return useAppStore().activeAccount.privkey
}
},
},
methods: {
hexToBech32,
@ -63,7 +72,7 @@ export default {
},
dismiss() {
this.dialogOpen = false
}
},
},
}
</script>
@ -83,17 +92,17 @@ export default {
position: absolute;
width: 16px;
height: 16px;
top: .5rem;
left: .5rem;
top: 0.5rem;
left: 0.5rem;
fill: #fff;
}
h3 {
margin-top: 3rem;
padding: 0 .5rem;
padding: 0 0.5rem;
}
> p {
padding: 0 .5rem;
padding: 0 0.5rem;
}
.warning {

View File

@ -1,14 +1,14 @@
<template>
<span v-if="verified" class="nip05-badge">
<q-icon name="verified" :size="size" color="primary">
<q-tooltip>NIP05 verified</q-tooltip>
<q-tooltip>{{ $t("NIP05 verified") }}</q-tooltip>
</q-icon>
<span class="nip05-badge-text">{{ nip05 }}</span>
</span>
</template>
<script>
import {useNostrStore} from 'src/nostr/NostrStore'
import { useNostrStore } from 'src/nostr/NostrStore'
export default {
name: 'Nip05Badge',
@ -19,8 +19,8 @@ export default {
},
size: {
type: String,
default: '14px'
}
default: '14px',
},
},
setup() {
return {
@ -40,18 +40,22 @@ export default {
if (!this.profile?.nip05.url) return
return this.profile.nip05.url
.split('@')
.filter(part => part !== '_' && part?.toLowerCase() !== this.profile.name?.toLowerCase())
.filter(
(part) =>
part !== '_' &&
part?.toLowerCase() !== this.profile.name?.toLowerCase()
)
.join('@')
}
},
},
watch: {
async profile() {
this.verified = await this.profile?.isNip05Verified()
}
},
},
async mounted() {
this.verified = await this.profile?.isNip05Verified()
}
},
}
</script>

View File

@ -2,5 +2,6 @@
// so you can safely delete all default props below
export default {
'thread': 'Thread'
thread: 'Thread',
'Load {unread} unread': 'Load {unread} unread',
}

93
src/i18n/es/index.js Normal file
View File

@ -0,0 +1,93 @@
// This is just an example,
// so you can safely delete all default props below
export default {
About: 'Presentación',
'Add an account': 'Agregar cuenta',
'Add relay': 'Agregar relay',
April: 'Abril',
August: 'Agosto',
Cancels: 'Cancelar',
'Click to decrypt': 'Click para desencriptar',
Close: 'Cerrar',
Continue: 'Continuar',
Create: 'Crear',
'Create Account': 'Crear una cuenta',
December: 'Diciembre',
'Decryption failed': 'Fallo al desencriptar',
'Delete relay': 'Eliminar relay',
'Do you really want to log out from': '¿Quieres Cerrar sesión de',
'Failed to create profile': 'Fallo al crear el perfil',
'Failed to delete reaction': 'Fallo al eliminar reacción',
'Failed to publish post': 'No se pudo publicar la publicación',
'Failed to publish reaction': 'Fallo al publicar reacción',
'Failed to send message': 'Fallo al enviar mensaje',
'Failed to update followers': 'Fallo al actualizar lista de seguidores',
'Failed to update profile': 'Fallo al actualizar el perfil',
February: 'Febrero',
Follow: 'Seguir',
Followers: 'Seguidores',
Following: 'Seguidos',
Global: 'Global',
Hilo: 'Hilo',
Home: 'Inicio',
"icon in the recipient's profile.": 'en elperfil del destinatario.',
January: 'Enero',
'Jot something down': 'Apunta algo',
July: 'Julio',
June: 'Junio',
'Load {unread} unread': 'Cargar {unread} sin leer',
'Load more': 'Cargar más..',
'Loading...': 'Cargando...',
'Log in': 'Ingresar',
'Log in with Extension': 'Ingresar con extensión',
'Log in with key': 'Ingresar con clave',
'Log out': 'Cerrar sesión',
'Logout from': 'Cerrar sesión en',
'Make sure you have a backup of your private key! Otherwise it is impossible to log back in to your account.': '¡Asegúrate de tener una copia de seguridad de tu clave privada! De lo contrario, es imposible volver a iniciar sesión en su cuenta.',
March: 'Marzo',
'Mark all as read': 'Marcar todo como leido',
May: 'Mayo',
Message: 'Mensaje',
Messages: 'Mensajes',
Name: 'Nombre',
'New to Nostr?': '¿Nuevo en Nostr?',
'NIP05 Identifier': 'Identificador NIP05',
'NIP05 verified': 'NIP05 verificado',
'No more items. Try again?': 'No hay registros. ¿Intentar otra vez?',
'Nothing found.': 'No hay registros.',
'Nothing here.': 'Nada por aquí',
Notifications: 'Notificationes',
November: 'Noviembre',
October: 'Octubre',
'Paste your private key to continue': 'Pega tu clave privada para continuar',
'Paste your public or private key': 'Pega tu clave pública o privada',
'Picture URL': 'Imagen de perfil',
Post: 'Postear',
'Post published to {numRelays} relays': 'Posteo publicado en {numRelays} relays',
'Post your reply': 'Postea tu respuesta',
Posts: 'Posteos',
'Private key needed': 'Clave privada requira',
Profile: 'Perfil',
Reactions: 'Reacciones',
Relays: 'Relays',
Replies: 'Respuestas',
'Reply published to {numRelays} relays': 'Repuesta publicada en {numRelays} relays',
'Replying to': 'En respuesta a',
Reset: 'Resetear',
'Restore defaults': 'Restaurar predeterminados',
Save: 'Guardar',
'Search profiles': 'Buscar perfiles',
'Send private message': 'Enviar mensaje privado',
September: 'Septiembre',
Settings: 'Preferencias',
'This is the beginning of your message history with': 'Este es el comienzo de su historial de mensajes con',
thread: 'Hilo',
Thread: 'Hilo',
'Tip with Bitcoin Lightning': 'Dar propina con Bitcoin Lightning',
'To send a message, click on the': 'Para enviar un mensaje, haga clic en el icono',
'Try again?': '¿Intentar otra vez?',
Unfollow: 'Dejar de seguir',
"What's happening?": '¿Qué estas pensando?',
"What's your name?": 'Cual es tu nombre?'
}

View File

@ -1,5 +1,7 @@
import enUS from './en-US'
import en from './en'
import es from './es'
export default {
'en-US': enUS
en,
es,
}

View File

@ -10,10 +10,11 @@
size="sm"
class="feed-selector"
:options="[
{value: 'following', icon: 'group'},
{value: 'global', icon: 'public'},
{ value: 'following', icon: 'group', title: $t('Following') },
{ value: 'global', icon: 'public', title: $t('Global') },
]"
/>
>
</q-btn-toggle>
</template>
</PageHeader>
@ -30,22 +31,23 @@
</template>
<script>
import {defineComponent} from 'vue'
import { defineComponent } from 'vue'
import PageHeader from 'components/PageHeader.vue'
import PostEditor from 'components/CreatePost/PostEditor.vue'
import Feed from 'components/Feed/Feed.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {EventKind} from 'src/nostr/model/Event'
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { EventKind } from 'src/nostr/model/Event'
import { NoteOrder, useNoteStore } from 'src/nostr/store/NoteStore'
const ZERO_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000000'
const ZERO_PUBKEY =
'0000000000000000000000000000000000000000000000000000000000000000'
const myContacts = () => {
const app = useAppStore()
const nostr = useNostrStore()
const contacts = nostr.getContacts(app.myPubkey)
return contacts?.map(contact => contact.pubkey).concat(app.myPubkey)
return contacts?.map((contact) => contact.pubkey).concat(app.myPubkey)
}
const Feeds = {
@ -74,7 +76,9 @@ const Feeds = {
let notes = []
const store = useNoteStore()
for (const author of authors) {
notes = notes.concat(store.postsByAuthor(author, NoteOrder.CREATION_DATE_DESC))
notes = notes.concat(
store.postsByAuthor(author, NoteOrder.CREATION_DATE_DESC)
)
}
return notes
},
@ -122,15 +126,14 @@ export default defineComponent({
if (this.initialized) return
if (!this.contacts) return
this.initialized = true
this.activeFeed = this.contacts?.length
? 'following'
: 'global'
this.activeFeed = this.contacts?.length ? 'following' : 'global'
},
onFeedLoaded(feed) {
if (this.activeFeed === 'following'
&& feed?.name === this.activeFeed
&& !this.contacts?.length
&& !this.initialized
if (
this.activeFeed === 'following' &&
feed?.name === this.activeFeed &&
!this.contacts?.length &&
!this.initialized
) {
this.activeFeed = 'global'
this.initialized = true
@ -145,7 +148,7 @@ export default defineComponent({
},
mounted() {
this.initFeed()
}
},
})
</script>

View File

@ -18,7 +18,8 @@
:connector="ancestors?.length > 0"
/>
<div v-else style="padding-left: 1.5rem">
<q-spinner size="sm" style="margin-right: .5rem"/> Loading...
<q-spinner size="sm" style="margin-right: 0.5rem" />
{{ $t("Loading...") }}
</div>
</q-item>
@ -28,18 +29,18 @@
</div>
</div>
<div style="min-height: 80vh;" />
<div style="min-height: 80vh" />
</q-page>
</template>
<script>
import {defineComponent} from 'vue'
import { defineComponent } from 'vue'
import PageHeader from 'components/PageHeader.vue'
import Thread from 'components/Post/Thread.vue'
import HeroPost from 'components/Post/HeroPost.vue'
import {useNostrStore} from 'src/nostr/NostrStore'
import {NoteOrder} from 'src/nostr/store/NoteStore'
import {bech32ToHex} from 'src/utils/utils'
import { useNostrStore } from 'src/nostr/NostrStore'
import { NoteOrder } from 'src/nostr/store/NoteStore'
import { bech32ToHex } from 'src/utils/utils'
export default defineComponent({
name: 'ThreadPage',
@ -50,7 +51,7 @@ export default defineComponent({
},
setup() {
return {
nostr: useNostrStore()
nostr: useNostrStore(),
}
},
data() {
@ -72,9 +73,7 @@ export default defineComponent({
},
rootId() {
if (!this.noteLoaded) return
return this.note.hasAncestor()
? this.note.root()
: this.note.id
return this.note.hasAncestor() ? this.note.root() : this.note.id
},
root() {
if (!this.rootId) return
@ -89,7 +88,9 @@ export default defineComponent({
const ancestors = this.allAncestors(this.note)
// Sanity check
if (ancestors.length > 0 && ancestors[0].id !== this.rootId) {
console.error(`Invalid thread structure: expected root ${this.rootId} but found ${ancestors[0].id}`)
console.error(
`Invalid thread structure: expected root ${this.rootId} but found ${ancestors[0].id}`
)
// return
}
return this.collectPredecessors(ancestors, this.note)
@ -118,13 +119,14 @@ export default defineComponent({
if (!ancestors || !ancestors.length) return []
const ancestor = ancestors.pop()
const replies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
const targetIdx = replies.findIndex(reply => reply.id === target.id)
const replies = this.nostr.getRepliesTo(
ancestor.id,
NoteOrder.CREATION_DATE_ASC
)
const targetIdx = replies.findIndex((reply) => reply.id === target.id)
const predecessors = [ancestor].concat(replies.slice(0, targetIdx))
return this
.collectPredecessors(ancestors, ancestor)
.concat(predecessors)
return this.collectPredecessors(ancestors, ancestor).concat(predecessors)
},
collectChildren(target, ancestor) {
@ -132,8 +134,13 @@ export default defineComponent({
// Get same-level successors
if (ancestor) {
const ancestorReplies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
const targetIdx = ancestorReplies.findIndex(reply => reply.id === target.id)
const ancestorReplies = this.nostr.getRepliesTo(
ancestor.id,
NoteOrder.CREATION_DATE_ASC
)
const targetIdx = ancestorReplies.findIndex(
(reply) => reply.id === target.id
)
const successors = ancestorReplies.slice(targetIdx + 1)
if (successors.length) {
children.push(successors)
@ -141,7 +148,10 @@ export default defineComponent({
}
// Get children of target
const targetReplies = this.nostr.getRepliesTo(target.id, NoteOrder.CREATION_DATE_DESC)
const targetReplies = this.nostr.getRepliesTo(
target.id,
NoteOrder.CREATION_DATE_DESC
)
for (const reply of targetReplies) {
children.push(this.unrollLongest(reply))
}
@ -152,7 +162,10 @@ export default defineComponent({
// Unrolls linear replies until first "fork"
unrollLinear(root) {
const thread = [root]
let replies = this.nostr.getRepliesTo(root.id, NoteOrder.CREATION_DATE_ASC)
let replies = this.nostr.getRepliesTo(
root.id,
NoteOrder.CREATION_DATE_ASC
)
while (replies.length === 1) {
thread.push(replies[0])
root = replies[0]
@ -164,7 +177,10 @@ export default defineComponent({
// Unrolls the longest thread in the subtree
unrollLongest(root) {
let threads = []
let replies = this.nostr.getRepliesTo(root.id, NoteOrder.CREATION_DATE_ASC)
let replies = this.nostr.getRepliesTo(
root.id,
NoteOrder.CREATION_DATE_ASC
)
for (const reply of replies) {
threads.push(this.unrollLongest(reply))
}
@ -205,7 +221,7 @@ export default defineComponent({
if (this.rootLoaded) {
this.startStream()
}
}
},
},
mounted() {
this.startStream()
@ -217,7 +233,7 @@ export default defineComponent({
unmounted() {
this.closeStream()
this.resizeObserver.disconnect()
}
},
})
</script>

View File

@ -12,7 +12,8 @@
/>
<p v-if="!conversation?.length" class="placeholder">
<template v-if="counterparty !== app.myPubkey">
This is the beginning of your message history with <UserName :pubkey="counterparty" clickable />.
{{ $t("This is the beginning of your message history with") }}
<UserName :pubkey="counterparty" clickable />.
</template>
<template v-else>
Keep private notes by sending messages to yourself.
@ -21,7 +22,12 @@
</div>
<div class="conversation-reply">
<MessageEditor :recipient="counterparty" :placeholder="placeholder" @publish="onPublish" autofocus />
<MessageEditor
:recipient="counterparty"
:placeholder="$t(placeholder)"
@publish="onPublish"
autofocus
/>
</div>
</template>
@ -31,13 +37,13 @@ import UserCard from 'components/User/UserCard.vue'
import MessageEditor from 'components/Message/MessageEditor.vue'
import UserName from 'components/User/UserName.vue'
import ChatMessage from 'components/Message/ChatMessage.vue'
import {useMessageStore} from 'src/nostr/store/MessageStore'
import {useAppStore} from 'stores/App'
import {bech32ToHex} from 'src/utils/utils'
import { useMessageStore } from 'src/nostr/store/MessageStore'
import { useAppStore } from 'stores/App'
import { bech32ToHex } from 'src/utils/utils'
export default {
name: 'Conversation',
components: {ChatMessage, UserName, MessageEditor, PageHeader, UserCard},
components: { ChatMessage, UserName, MessageEditor, PageHeader, UserCard },
setup() {
return {
app: useAppStore(),
@ -50,10 +56,12 @@ export default {
},
conversation() {
if (!this.app.isSignedIn) return
return this.messages.getConversation(this.app.myPubkey, this.counterparty)
return this.messages.getConversation(
this.app.myPubkey,
this.counterparty
)
},
placeholder() {
// TODO i18n
return this.app.myPubkey === this.counterparty
? 'Jot something down'
: 'Message'
@ -81,7 +89,7 @@ export default {
},
unmounted() {
this.resizeObserver.disconnect()
}
},
}
</script>
@ -107,7 +115,11 @@ export default {
}
.conversation-reply {
background: linear-gradient(to bottom, rgba($color: $color-bg, $alpha: 0), rgba($color: $color-bg, $alpha: 1) 6%);
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;
@ -140,5 +152,4 @@ export default {
padding-bottom: 1rem;
}
}
</style>

View File

@ -2,24 +2,39 @@
<PageHeader back-button>
<template #addon>
<q-btn icon="more_vert" size="md" round flat>
<q-menu anchor="bottom right" self="top right" :offset="[0, 6]" class="options-popup">
<a @click="markAllAsRead" v-close-popup>Mark all as read</a>
<q-menu
anchor="bottom right"
self="top right"
:offset="[0, 6]"
class="options-popup"
>
<a @click="markAllAsRead" v-close-popup>{{
$t("Mark all as read")
}}</a>
</q-menu>
</q-btn>
</template>
</PageHeader>
<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>
<ConversationItem
v-for="conversation in conversations"
:key="conversation.pubkey"
:conversation="conversation"
/>
<p v-if="!conversations?.length">
{{ $t("To send a message, click on the") }}
<BaseIcon icon="messages" />
{{ $t("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 { 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'
@ -47,7 +62,7 @@ export default {
markAllAsRead() {
this.messages.markAllAsRead(this.app.myPubkey)
},
}
},
}
</script>
@ -76,10 +91,10 @@ p {
.options-popup {
background-color: $color-bg;
box-shadow: $shadow-white;
border-radius: .5rem;
border-radius: 0.5rem;
a {
display: block;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
transition: 120ms ease;
&:hover {
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);

View File

@ -12,8 +12,8 @@
indicator-color="primary"
:breakpoint="0"
>
<q-tab name="following" label="Following" />
<q-tab name="followers" label="Followers" />
<q-tab name="following" :label="$t('Following')" />
<q-tab name="followers" :label="$t('Followers')" />
</q-tabs>
</div>
@ -41,12 +41,12 @@
</template>
<script>
import {defineComponent} from 'vue'
import { defineComponent } from 'vue'
import PageHeader from 'components/PageHeader.vue'
import UserCard from 'components/User/UserCard.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { bech32ToHex, hexToBech32 } from 'src/utils/utils'
import UserName from 'components/User/UserName.vue'
export default defineComponent({
@ -91,11 +91,11 @@ export default defineComponent({
activeTab() {
this.$router.replace({
params: {
tab: this.activeTab
}
tab: this.activeTab,
},
})
}
}
},
},
})
</script>
@ -133,7 +133,8 @@ export default defineComponent({
}
}
.profile-header-content .username {
.name, .pubkey:first-child {
.name,
.pubkey:first-child {
font-size: 1.4rem;
}
}

View File

@ -12,20 +12,23 @@
<p class="about">{{ profile?.about }}</p>
<p class="followers">
<a @click="goToFollowers('following')">
<strong>{{ contacts?.length || 0 }}</strong> Following
<strong>{{ contacts?.length || 0 }}</strong> {{ $t("Following") }}
</a>
<a @click="goToFollowers('followers')">
<strong>{{ followers?.length ? `${followers?.length}+` : 0 }}</strong> Followers
<strong>{{
followers?.length ? `${followers?.length}+` : 0
}}</strong>
{{ $t("Followers") }}
</a>
</p>
<p class="actions">
<a @click="goToConversation">
<BaseIcon icon="messages" />
<q-tooltip>Send private message</q-tooltip>
<q-tooltip>{{ $t("Send private message") }}</q-tooltip>
</a>
<a :href="lightningLink" :class="{disabled: !lightningLink}">
<a :href="lightningLink" :class="{ disabled: !lightningLink }">
<q-icon name="bolt" size="sm" />
<q-tooltip>Tip with Bitcoin Lightning</q-tooltip>
<q-tooltip>{{ $t("Tip with Bitcoin Lightning") }}</q-tooltip>
</a>
</p>
</div>
@ -39,10 +42,10 @@
indicator-color="primary"
:breakpoint="0"
>
<q-tab name="posts" label="Posts" />
<q-tab name="replies" label="Replies" />
<q-tab name="reactions" label="Reactions" />
<q-tab name="relays" label="Relays" />
<q-tab name="posts" :label="$t('Posts')" />
<q-tab name="replies" :label="$t('Replies')" />
<q-tab name="reactions" :label="$t('Reactions')" />
<q-tab name="relays" :label="$t('Relays')" />
</q-tabs>
</div>
@ -61,23 +64,21 @@
</q-tab-panel>
<q-tab-panel name="replies" class="no-padding">
<template v-for="(thread, i) in replies">
<Thread
v-if="defer(i)"
:key="thread[1].id"
:thread="thread"
/>
<Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
</template>
<AsyncLoadLink :load-fn="loadMorePosts" :has-items="!!replies?.length" />
<AsyncLoadLink
:load-fn="loadMorePosts"
:has-items="!!replies?.length"
/>
</q-tab-panel>
<q-tab-panel name="reactions" class="no-padding">
<template v-for="(thread, i) in reactions">
<Thread
v-if="defer(i)"
:key="thread[1].id"
:thread="thread"
/>
<Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
</template>
<AsyncLoadLink :load-fn="loadMoreReactions" :has-items="!!reactions?.length" />
<AsyncLoadLink
:load-fn="loadMoreReactions"
:has-items="!!reactions?.length"
/>
</q-tab-panel>
<q-tab-panel name="relays" class="no-padding">
<ListPlaceholder :count="0" />
@ -87,7 +88,7 @@
</template>
<script>
import {defineComponent} from 'vue'
import { defineComponent } from 'vue'
import PageHeader from 'components/PageHeader.vue'
import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.vue'
@ -97,11 +98,11 @@ import ListPlaceholder from 'components/ListPlaceholder.vue'
import FollowButton from 'components/User/FollowButton.vue'
import BaseIcon from 'components/BaseIcon/index.vue'
import AsyncLoadLink from 'components/AsyncLoadLink.vue'
import {useAppStore} from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore'
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
import { useAppStore } from 'stores/App'
import { useNostrStore } from 'src/nostr/NostrStore'
import { bech32ToHex, hexToBech32 } from 'src/utils/utils'
import Defer from 'src/utils/Defer'
import {EventKind} from 'src/nostr/model/Event'
import { EventKind } from 'src/nostr/model/Event'
import DateUtils from 'src/utils/DateUtils'
export default defineComponent({
@ -143,16 +144,18 @@ export default defineComponent({
return this.nostr.getPostsByAuthor(this.pubkey)
},
posts() {
return this.notes?.filter(note => !note.hasAncestor())
return this.notes?.filter((note) => !note.hasAncestor())
},
replies() {
return this.notes?.filter(note => note.hasAncestor())
.map(note => [this.nostr.getNote(note.ancestor()), note])
return this.notes
?.filter((note) => note.hasAncestor())
.map((note) => [this.nostr.getNote(note.ancestor()), note])
.slice(0, 50)
},
reactions() {
return this.nostr.getReactionsByAuthor(this.pubkey)
.map(note => [this.nostr.getNote(note.ancestor()), note])
return this.nostr
.getReactionsByAuthor(this.pubkey)
.map((note) => [this.nostr.getNote(note.ancestor()), note])
.slice(0, 50)
},
relays() {
@ -174,7 +177,8 @@ export default defineComponent({
},
methods: {
loadMorePosts() {
const oldest = this.notes?.[this.notes.length - 1]?.createdAt || DateUtils.now()
const oldest =
this.notes?.[this.notes.length - 1]?.createdAt || DateUtils.now()
return this.nostr.fetch({
kinds: [EventKind.NOTE],
authors: [this.pubkey],
@ -183,7 +187,9 @@ export default defineComponent({
})
},
loadMoreReactions() {
const oldest = this.reactions?.[this.reactions.length - 1]?.createdAt || DateUtils.now()
const oldest =
this.reactions?.[this.reactions.length - 1]?.createdAt ||
DateUtils.now()
return this.nostr.fetch({
kinds: [EventKind.REACTION],
authors: [this.pubkey],
@ -197,7 +203,7 @@ export default defineComponent({
params: {
pubkey: hexToBech32(this.pubkey, 'npub'),
tab,
}
},
})
},
goToConversation() {
@ -205,7 +211,7 @@ export default defineComponent({
name: 'conversation',
params: {
pubkey: hexToBech32(this.pubkey, 'npub'),
}
},
})
},
},
@ -213,16 +219,18 @@ export default defineComponent({
activeTab() {
this.$router.replace({
params: {
tab: this.activeTab
}
tab: this.activeTab,
},
})
},
},
mounted() {
this.nostr.fetchPostsByAuthor(this.pubkey, 50)
.then(() => this.loadingNotes = false)
this.nostr.fetchReactionsByAuthor(this.pubkey, 50)
.then(() => this.loadingReactions = false)
this.nostr
.fetchPostsByAuthor(this.pubkey, 50)
.then(() => (this.loadingNotes = false))
this.nostr
.fetchReactionsByAuthor(this.pubkey, 50)
.then(() => (this.loadingReactions = false))
this.nostr.fetchFollowers(this.pubkey, 1000)
// FIXME
@ -230,7 +238,7 @@ export default defineComponent({
},
unmounted() {
if (this.stream) this.nostr.cancelStream(this.stream)
}
},
})
</script>
@ -262,7 +270,8 @@ export default defineComponent({
a {
cursor: pointer;
color: $color-light-gray;
&:hover, &:active {
&:hover,
&:active {
text-decoration: underline;
}
strong {
@ -277,7 +286,8 @@ export default defineComponent({
display: flex;
a {
text-decoration: none;
svg, i {
svg,
i {
width: 24px;
height: 24px;
color: $color-light-gray;
@ -285,20 +295,22 @@ export default defineComponent({
transition: 120ms ease;
}
&.disabled {
svg, i {
svg,
i {
color: $color-dark-gray !important;
fill: $color-dark-gray !important;
}
}
&:hover {
svg, i {
svg,
i {
fill: $color-fg;
color: $color-fg;
}
}
}
a + a {
margin-left: .5rem;
margin-left: 0.5rem;
}
}
}

View File

@ -1,4 +1,6 @@
import moment from 'moment/moment'
import { formatDistanceToNow } from 'date-fns'
import { es, enUS } from 'date-fns/locale'
import { $t } from '../boot/i18n'
// TODO i18n
const MONTHS = [
@ -13,9 +15,11 @@ const MONTHS = [
'September',
'October',
'November',
'December'
'December',
]
const [lng = 'en'] = (navigator?.language || '').split('-')
export default class DateUtils {
static now() {
return Math.floor(Date.now() / 1000)
@ -23,9 +27,9 @@ export default class DateUtils {
static formatDate(timestamp) {
const date = new Date(timestamp * 1000)
const month = MONTHS[date.getMonth()] // TODO i18n
const month = $t(MONTHS[date.getMonth()]) // TODO i18n
const sameYear = date.getFullYear() === (new Date().getFullYear())
const sameYear = date.getFullYear() === new Date().getFullYear()
const year = !sameYear ? ' ' + date.getFullYear() : ''
return `${date.getDate()} ${month}${year}`
@ -39,7 +43,9 @@ export default class DateUtils {
}
static formatDateTime(timestamp) {
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(timestamp)}`
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(
timestamp
)}`
}
static formatFromNow(timestamp, format = 'long') {
@ -49,18 +55,22 @@ export default class DateUtils {
}
static formatFromNowLong(timestamp) {
return moment(timestamp * 1000).fromNow()
return formatDistanceToNow(timestamp * 1000, {
locale: lng === 'es' ? es : enUS,
})
}
static formatFromNowShort(timestamp) {
const diff = Math.max(DateUtils.now() - timestamp, 0)
const formatDiff = (unit, factor, offset) => Math.max(Math.floor((diff + (unit * offset)) / (unit * factor)), 1)
const formatDiff = (unit, factor, offset) =>
Math.max(Math.floor((diff + unit * offset) / (unit * factor)), 1)
if (diff < 45) return `${formatDiff(1, 1, 0)}s`
if (diff < 60 * 45) return `${formatDiff(1, 60, 15)}m`
if (diff < 60 * 60 * 22) return `${formatDiff(60, 60, 15)}h`
if (diff < 60 * 60 * 24 * 26) return `${formatDiff(60 * 60, 24, 2)}d`
if (diff < 60 * 60 * 24 * 30 * 320) return `${formatDiff(60 * 60 * 24, 30, 4)}mo`
if (diff < 60 * 60 * 24 * 30 * 320)
return `${formatDiff(60 * 60 * 24, 30, 4)}mo`
return `${formatDiff(60 * 60 * 24, 30 * 365, 45)}y`
}
}

View File

@ -2618,6 +2618,11 @@ csstype@^2.6.8:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
date-fns@^2.29.3:
version "2.29.3"
resolved "https://registry.npmmirror.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -4316,11 +4321,6 @@ minimist@^1.2.0:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
moment@^2.29.4:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
mrmime@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27"