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.guides.bracketPairs": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": [ "editor.codeActionsOnSave": ["source.fixAll.eslint"],
"source.fixAll.eslint" "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"]
], }
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"vue"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,25 @@
<template> <template>
<PostRenderer v-if="note" :note="note" /> <PostRenderer v-if="note" :note="note" />
<span v-else-if="!decryptFailed" class="click-to-decrypt" @click="clickToDecrypt && decrypt()">Click to decrypt</span> <span
<span v-else class="decrypt-failed" @click="decrypt">Decryption failed</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> </template>
<script> <script>
import {useAppStore} from 'stores/App' import { useAppStore } from 'stores/App'
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue' import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
import Note from 'src/nostr/model/Note' import Note from 'src/nostr/model/Note'
export default { export default {
name: 'EncryptedMessage', name: 'EncryptedMessage',
components: {PostRenderer}, components: { PostRenderer },
props: { props: {
message: { message: {
type: Object, type: Object,
@ -20,7 +28,7 @@ export default {
clickToDecrypt: { clickToDecrypt: {
type: Boolean, type: Boolean,
default: false, default: false,
} },
}, },
setup() { setup() {
return { return {
@ -38,17 +46,21 @@ export default {
const note = new Note(this.message.id, this.message) const note = new Note(this.message.id, this.message)
note.content = this.message.plaintext note.content = this.message.plaintext
return note return note
} },
}, },
methods: { methods: {
async decrypt() { async decrypt() {
if (this.message.plaintext) return if (this.message.plaintext) return
try { try {
const messageId = this.message.id const messageId = this.message.id
const counterparty = this.message.author === this.app.myPubkey const counterparty =
? this.message.recipient this.message.author === this.app.myPubkey
: this.message.author ? this.message.recipient
const plaintext = await this.app.decryptMessage(counterparty, this.message.content) : 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. // 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) { if (this.message.id === messageId) {
this.message.cachePlaintext(plaintext) this.message.cachePlaintext(plaintext)
@ -57,7 +69,7 @@ export default {
console.error('Failed to decrypt message', e) console.error('Failed to decrypt message', e)
this.decryptFailed = true this.decryptFailed = true
} }
} },
}, },
async mounted() { async mounted() {
if (this.app.activeAccount.canDecrypt()) { if (this.app.activeAccount.canDecrypt()) {
@ -69,8 +81,8 @@ export default {
if (this.app.activeAccount.canDecrypt()) { if (this.app.activeAccount.canDecrypt()) {
await this.decrypt() await this.decrypt()
} }
} },
} },
} }
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,5 +2,6 @@
// so you can safely delete all default props below // so you can safely delete all default props below
export default { 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 { export default {
'en-US': enUS en,
es,
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,20 +12,23 @@
<p class="about">{{ profile?.about }}</p> <p class="about">{{ profile?.about }}</p>
<p class="followers"> <p class="followers">
<a @click="goToFollowers('following')"> <a @click="goToFollowers('following')">
<strong>{{ contacts?.length || 0 }}</strong> Following <strong>{{ contacts?.length || 0 }}</strong> {{ $t("Following") }}
</a> </a>
<a @click="goToFollowers('followers')"> <a @click="goToFollowers('followers')">
<strong>{{ followers?.length ? `${followers?.length}+` : 0 }}</strong> Followers <strong>{{
followers?.length ? `${followers?.length}+` : 0
}}</strong>
{{ $t("Followers") }}
</a> </a>
</p> </p>
<p class="actions"> <p class="actions">
<a @click="goToConversation"> <a @click="goToConversation">
<BaseIcon icon="messages" /> <BaseIcon icon="messages" />
<q-tooltip>Send private message</q-tooltip> <q-tooltip>{{ $t("Send private message") }}</q-tooltip>
</a> </a>
<a :href="lightningLink" :class="{disabled: !lightningLink}"> <a :href="lightningLink" :class="{ disabled: !lightningLink }">
<q-icon name="bolt" size="sm" /> <q-icon name="bolt" size="sm" />
<q-tooltip>Tip with Bitcoin Lightning</q-tooltip> <q-tooltip>{{ $t("Tip with Bitcoin Lightning") }}</q-tooltip>
</a> </a>
</p> </p>
</div> </div>
@ -39,10 +42,10 @@
indicator-color="primary" indicator-color="primary"
:breakpoint="0" :breakpoint="0"
> >
<q-tab name="posts" label="Posts" /> <q-tab name="posts" :label="$t('Posts')" />
<q-tab name="replies" label="Replies" /> <q-tab name="replies" :label="$t('Replies')" />
<q-tab name="reactions" label="Reactions" /> <q-tab name="reactions" :label="$t('Reactions')" />
<q-tab name="relays" label="Relays" /> <q-tab name="relays" :label="$t('Relays')" />
</q-tabs> </q-tabs>
</div> </div>
@ -61,23 +64,21 @@
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="replies" class="no-padding"> <q-tab-panel name="replies" class="no-padding">
<template v-for="(thread, i) in replies"> <template v-for="(thread, i) in replies">
<Thread <Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
v-if="defer(i)"
:key="thread[1].id"
:thread="thread"
/>
</template> </template>
<AsyncLoadLink :load-fn="loadMorePosts" :has-items="!!replies?.length" /> <AsyncLoadLink
:load-fn="loadMorePosts"
:has-items="!!replies?.length"
/>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="reactions" class="no-padding"> <q-tab-panel name="reactions" class="no-padding">
<template v-for="(thread, i) in reactions"> <template v-for="(thread, i) in reactions">
<Thread <Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
v-if="defer(i)"
:key="thread[1].id"
:thread="thread"
/>
</template> </template>
<AsyncLoadLink :load-fn="loadMoreReactions" :has-items="!!reactions?.length" /> <AsyncLoadLink
:load-fn="loadMoreReactions"
:has-items="!!reactions?.length"
/>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="relays" class="no-padding"> <q-tab-panel name="relays" class="no-padding">
<ListPlaceholder :count="0" /> <ListPlaceholder :count="0" />
@ -87,7 +88,7 @@
</template> </template>
<script> <script>
import {defineComponent} from 'vue' import { defineComponent } from 'vue'
import PageHeader from 'components/PageHeader.vue' import PageHeader from 'components/PageHeader.vue'
import UserAvatar from 'components/User/UserAvatar.vue' import UserAvatar from 'components/User/UserAvatar.vue'
import UserName from 'components/User/UserName.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 FollowButton from 'components/User/FollowButton.vue'
import BaseIcon from 'components/BaseIcon/index.vue' import BaseIcon from 'components/BaseIcon/index.vue'
import AsyncLoadLink from 'components/AsyncLoadLink.vue' import AsyncLoadLink from 'components/AsyncLoadLink.vue'
import {useAppStore} from 'stores/App' import { useAppStore } from 'stores/App'
import {useNostrStore} from 'src/nostr/NostrStore' import { useNostrStore } from 'src/nostr/NostrStore'
import {bech32ToHex, hexToBech32} from 'src/utils/utils' import { bech32ToHex, hexToBech32 } from 'src/utils/utils'
import Defer from 'src/utils/Defer' 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' import DateUtils from 'src/utils/DateUtils'
export default defineComponent({ export default defineComponent({
@ -143,16 +144,18 @@ export default defineComponent({
return this.nostr.getPostsByAuthor(this.pubkey) return this.nostr.getPostsByAuthor(this.pubkey)
}, },
posts() { posts() {
return this.notes?.filter(note => !note.hasAncestor()) return this.notes?.filter((note) => !note.hasAncestor())
}, },
replies() { replies() {
return this.notes?.filter(note => note.hasAncestor()) return this.notes
.map(note => [this.nostr.getNote(note.ancestor()), note]) ?.filter((note) => note.hasAncestor())
.map((note) => [this.nostr.getNote(note.ancestor()), note])
.slice(0, 50) .slice(0, 50)
}, },
reactions() { reactions() {
return this.nostr.getReactionsByAuthor(this.pubkey) return this.nostr
.map(note => [this.nostr.getNote(note.ancestor()), note]) .getReactionsByAuthor(this.pubkey)
.map((note) => [this.nostr.getNote(note.ancestor()), note])
.slice(0, 50) .slice(0, 50)
}, },
relays() { relays() {
@ -174,7 +177,8 @@ export default defineComponent({
}, },
methods: { methods: {
loadMorePosts() { 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({ return this.nostr.fetch({
kinds: [EventKind.NOTE], kinds: [EventKind.NOTE],
authors: [this.pubkey], authors: [this.pubkey],
@ -183,7 +187,9 @@ export default defineComponent({
}) })
}, },
loadMoreReactions() { 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({ return this.nostr.fetch({
kinds: [EventKind.REACTION], kinds: [EventKind.REACTION],
authors: [this.pubkey], authors: [this.pubkey],
@ -197,7 +203,7 @@ export default defineComponent({
params: { params: {
pubkey: hexToBech32(this.pubkey, 'npub'), pubkey: hexToBech32(this.pubkey, 'npub'),
tab, tab,
} },
}) })
}, },
goToConversation() { goToConversation() {
@ -205,7 +211,7 @@ export default defineComponent({
name: 'conversation', name: 'conversation',
params: { params: {
pubkey: hexToBech32(this.pubkey, 'npub'), pubkey: hexToBech32(this.pubkey, 'npub'),
} },
}) })
}, },
}, },
@ -213,16 +219,18 @@ export default defineComponent({
activeTab() { activeTab() {
this.$router.replace({ this.$router.replace({
params: { params: {
tab: this.activeTab tab: this.activeTab,
} },
}) })
}, },
}, },
mounted() { mounted() {
this.nostr.fetchPostsByAuthor(this.pubkey, 50) this.nostr
.then(() => this.loadingNotes = false) .fetchPostsByAuthor(this.pubkey, 50)
this.nostr.fetchReactionsByAuthor(this.pubkey, 50) .then(() => (this.loadingNotes = false))
.then(() => this.loadingReactions = false) this.nostr
.fetchReactionsByAuthor(this.pubkey, 50)
.then(() => (this.loadingReactions = false))
this.nostr.fetchFollowers(this.pubkey, 1000) this.nostr.fetchFollowers(this.pubkey, 1000)
// FIXME // FIXME
@ -230,7 +238,7 @@ export default defineComponent({
}, },
unmounted() { unmounted() {
if (this.stream) this.nostr.cancelStream(this.stream) if (this.stream) this.nostr.cancelStream(this.stream)
} },
}) })
</script> </script>
@ -262,7 +270,8 @@ export default defineComponent({
a { a {
cursor: pointer; cursor: pointer;
color: $color-light-gray; color: $color-light-gray;
&:hover, &:active { &:hover,
&:active {
text-decoration: underline; text-decoration: underline;
} }
strong { strong {
@ -277,7 +286,8 @@ export default defineComponent({
display: flex; display: flex;
a { a {
text-decoration: none; text-decoration: none;
svg, i { svg,
i {
width: 24px; width: 24px;
height: 24px; height: 24px;
color: $color-light-gray; color: $color-light-gray;
@ -285,20 +295,22 @@ export default defineComponent({
transition: 120ms ease; transition: 120ms ease;
} }
&.disabled { &.disabled {
svg, i { svg,
i {
color: $color-dark-gray !important; color: $color-dark-gray !important;
fill: $color-dark-gray !important; fill: $color-dark-gray !important;
} }
} }
&:hover { &:hover {
svg, i { svg,
i {
fill: $color-fg; fill: $color-fg;
color: $color-fg; color: $color-fg;
} }
} }
} }
a + a { 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 // TODO i18n
const MONTHS = [ const MONTHS = [
@ -13,9 +15,11 @@ const MONTHS = [
'September', 'September',
'October', 'October',
'November', 'November',
'December' 'December',
] ]
const [lng = 'en'] = (navigator?.language || '').split('-')
export default class DateUtils { export default class DateUtils {
static now() { static now() {
return Math.floor(Date.now() / 1000) return Math.floor(Date.now() / 1000)
@ -23,9 +27,9 @@ export default class DateUtils {
static formatDate(timestamp) { static formatDate(timestamp) {
const date = new Date(timestamp * 1000) 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() : '' const year = !sameYear ? ' ' + date.getFullYear() : ''
return `${date.getDate()} ${month}${year}` return `${date.getDate()} ${month}${year}`
@ -39,7 +43,9 @@ export default class DateUtils {
} }
static formatDateTime(timestamp) { static formatDateTime(timestamp) {
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(timestamp)}` return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(
timestamp
)}`
} }
static formatFromNow(timestamp, format = 'long') { static formatFromNow(timestamp, format = 'long') {
@ -49,18 +55,22 @@ export default class DateUtils {
} }
static formatFromNowLong(timestamp) { static formatFromNowLong(timestamp) {
return moment(timestamp * 1000).fromNow() return formatDistanceToNow(timestamp * 1000, {
locale: lng === 'es' ? es : enUS,
})
} }
static formatFromNowShort(timestamp) { static formatFromNowShort(timestamp) {
const diff = Math.max(DateUtils.now() - timestamp, 0) 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 < 45) return `${formatDiff(1, 1, 0)}s`
if (diff < 60 * 45) return `${formatDiff(1, 60, 15)}m` 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 * 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 * 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` 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" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w== 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: debug@2.6.9:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 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" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== 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: mrmime@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27"