mirror of
https://github.com/styppo/hamstr.git
synced 2024-09-16 15:03:30 +00:00
feat(i18n): add spanish translations
This commit is contained in:
parent
6513463cd2
commit
b389d3feb9
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@ -3,13 +3,6 @@
|
||||
"editor.guides.bracketPairs": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll.eslint"
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"vue"
|
||||
]
|
||||
}
|
||||
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"]
|
||||
}
|
||||
|
@ -15,6 +15,7 @@
|
||||
"bech32-buffer": "^0.2.1",
|
||||
"core-js": "^3.6.5",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"date-fns": "^2.29.3",
|
||||
"emoji-mart-vue-fast": "^12.0.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"light-bolt11-decoder": "^2.1.0",
|
||||
@ -23,7 +24,6 @@
|
||||
"markdown-it-emoji": "^2.0.2",
|
||||
"markdown-it-sub": "^1.0.0",
|
||||
"markdown-it-sup": "^1.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"nostr-tools": "^1.1.1",
|
||||
"pinia": "^2.0.11",
|
||||
"pinia-plugin-persistedstate": "^3.0.2",
|
||||
|
@ -2,13 +2,20 @@ import { boot } from 'quasar/wrappers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import messages from 'src/i18n'
|
||||
|
||||
export default boot(({ app }) => {
|
||||
const i18n = createI18n({
|
||||
locale: 'en-US',
|
||||
globalInjection: true,
|
||||
messages
|
||||
})
|
||||
const [lng = 'en'] = (navigator?.language || '').split('-')
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: lng,
|
||||
fallbackLocale: 'en',
|
||||
globalInjection: true,
|
||||
messages,
|
||||
})
|
||||
|
||||
export default boot(({ app }) => {
|
||||
// Set i18n instance on app
|
||||
app.use(i18n)
|
||||
})
|
||||
|
||||
const $t = i18n.global.t
|
||||
|
||||
export { $t }
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div ref="button" class="async-load-button">
|
||||
<q-btn
|
||||
:loading="loading"
|
||||
:label="noMore ? labelNoMore : label"
|
||||
:label="$t(noMore ? labelNoMore : label)"
|
||||
@click="load"
|
||||
size="md"
|
||||
flat
|
||||
@ -23,16 +23,16 @@ export default defineComponent({
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Load more'
|
||||
default: 'Load more',
|
||||
},
|
||||
labelNoMore: {
|
||||
type: String,
|
||||
default: 'No more items. Try again?'
|
||||
default: 'No more items. Try again?',
|
||||
},
|
||||
autoload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -53,7 +53,7 @@ export default defineComponent({
|
||||
|
||||
this.loading = false
|
||||
this.$emit('loaded', result)
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.autoload) {
|
||||
@ -66,7 +66,7 @@ export default defineComponent({
|
||||
},
|
||||
unmounted() {
|
||||
if (this.observer) this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -2,9 +2,9 @@
|
||||
<div ref="link" class="async-load-link" @click="load">
|
||||
<q-spinner v-if="loading" size="sm" />
|
||||
<span v-else>
|
||||
{{ noMore ? prefixNoMore : (!hasItems ? prefix : '') }}
|
||||
{{ $t(noMore ? prefixNoMore : !hasItems ? prefix : "") }}
|
||||
<a>
|
||||
{{ noMore ? labelNoMore : label }}
|
||||
{{ $t(noMore ? labelNoMore : label) }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@ -23,19 +23,19 @@ export default defineComponent({
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Load more'
|
||||
default: 'Load more',
|
||||
},
|
||||
labelNoMore: {
|
||||
type: String,
|
||||
default: 'Try again?'
|
||||
default: 'Try again?',
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'Nothing here.'
|
||||
default: 'Nothing here.',
|
||||
},
|
||||
prefixNoMore: {
|
||||
type: String,
|
||||
default: 'Nothing found.'
|
||||
default: 'Nothing found.',
|
||||
},
|
||||
hasItems: {
|
||||
type: Boolean,
|
||||
@ -44,7 +44,7 @@ export default defineComponent({
|
||||
autoload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -65,7 +65,7 @@ export default defineComponent({
|
||||
|
||||
this.loading = false
|
||||
this.$emit('loaded', result)
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.autoload) {
|
||||
@ -78,7 +78,7 @@ export default defineComponent({
|
||||
},
|
||||
unmounted() {
|
||||
if (this.observer) this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<textarea
|
||||
v-model="text"
|
||||
:placeholder="placeholder"
|
||||
:placeholder="$t(placeholder)"
|
||||
:disabled="disabled"
|
||||
:rows="rows"
|
||||
@input="resize"
|
||||
@ -30,7 +30,7 @@ export default {
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening?',
|
||||
default: "What's happening?",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@ -43,7 +43,7 @@ export default {
|
||||
submitOnEnter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'submit'],
|
||||
data() {
|
||||
@ -78,7 +78,11 @@ export default {
|
||||
},
|
||||
insertText(text) {
|
||||
const textarea = this.$refs.textarea
|
||||
textarea.setRangeText(text, textarea.selectionStart, textarea.selectionEnd)
|
||||
textarea.setRangeText(
|
||||
text,
|
||||
textarea.selectionStart,
|
||||
textarea.selectionEnd
|
||||
)
|
||||
|
||||
textarea.focus()
|
||||
if (textarea.selectionStart === textarea.selectionEnd) {
|
||||
@ -107,10 +111,9 @@ export default {
|
||||
if (this.text) {
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
@ -18,19 +18,19 @@ import 'emoji-mart-vue-fast/css/emoji-mart.css'
|
||||
export default {
|
||||
name: 'EmojiPicker',
|
||||
components: {
|
||||
Picker
|
||||
Picker,
|
||||
},
|
||||
emits: ['select'],
|
||||
emits: ['select'],
|
||||
data() {
|
||||
return {
|
||||
emojiIndex: new EmojiIndex(emojiData)
|
||||
emojiIndex: new EmojiIndex(emojiData),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSelect(emoji) {
|
||||
this.$emit('select', emoji)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="post-editor"
|
||||
:class="{compact, collapsed, connector}"
|
||||
>
|
||||
<div class="post-editor" :class="{ compact, collapsed, connector }">
|
||||
<div class="post-editor-author">
|
||||
<div v-if="connector" class="connector-top">
|
||||
<div class="connector-line"></div>
|
||||
@ -24,7 +21,7 @@
|
||||
<div class="controls-media-item">
|
||||
<BaseIcon icon="emoji" />
|
||||
<q-menu ref="menuEmojiPicker">
|
||||
<EmojiPicker @select="onEmojiSelected"/>
|
||||
<EmojiPicker @select="onEmojiSelected" />
|
||||
</q-menu>
|
||||
</div>
|
||||
<div class="controls-media-item disabled">
|
||||
@ -32,15 +29,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-submit">
|
||||
<button :disabled="!hasContent() || publishing" @click="publishPost" class="btn btn-primary btn-sm">
|
||||
<button
|
||||
:disabled="!hasContent() || publishing"
|
||||
@click="publishPost"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
<q-spinner v-if="publishing" />
|
||||
<span v-else>Post</span>
|
||||
<span v-else>{{ $t("Post") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-editor-fake-submit" v-if="collapsed">
|
||||
<button class="btn btn-primary btn-sm" disabled>Post</button>
|
||||
<button class="btn btn-primary btn-sm" disabled>{{ $t("Post") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -50,9 +51,10 @@ import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import EmojiPicker from 'components/CreatePost/EmojiPicker.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'PostEditor',
|
||||
@ -69,7 +71,7 @@ export default {
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening?',
|
||||
default: "What's happening?",
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
@ -82,7 +84,7 @@ export default {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ['publish'],
|
||||
data() {
|
||||
@ -116,25 +118,30 @@ export default {
|
||||
this.publishing = true
|
||||
|
||||
const event = this.ancestor
|
||||
? EventBuilder.reply(this.ancestor, this.app.myPubkey, this.content).build()
|
||||
? EventBuilder.reply(
|
||||
this.ancestor,
|
||||
this.app.myPubkey,
|
||||
this.content
|
||||
).build()
|
||||
: EventBuilder.post(this.app.myPubkey, this.content).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
|
||||
const numRelays = await this.nostr.publish(event)
|
||||
if (numRelays) {
|
||||
this.reset()
|
||||
this.$emit('publish', event)
|
||||
|
||||
// TODO i18n
|
||||
const postType = this.ancestor ? 'Reply' : 'Post'
|
||||
this.$q.notify({
|
||||
message: `${postType} published to ${numRelays} relays`,
|
||||
message: $t(`${postType} published to {numRelays} relays`, {
|
||||
numRelays,
|
||||
}),
|
||||
color: 'positive',
|
||||
})
|
||||
} else {
|
||||
this.$q.notify({
|
||||
message: `Failed to publish post`,
|
||||
color: 'negative'
|
||||
message: $t(`Failed to publish post`),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
|
||||
@ -204,11 +211,11 @@ export default {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
cursor:pointer;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
svg {
|
||||
width: 100%;
|
||||
fill: $color-primary
|
||||
fill: $color-primary;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
|
@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<div class="feed">
|
||||
<div class="load-more-container" :class="{'more-available': numUnreads}">
|
||||
<div class="load-more-container" :class="{ 'more-available': numUnreads }">
|
||||
<AsyncLoadButton
|
||||
v-if="numUnreads"
|
||||
:load-fn="loadNewer"
|
||||
:label="`Load ${numUnreads} unread`"
|
||||
:label="$t('Load {unread} unread', { unread: numUnreads })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Thread v-for="thread in visible" :key="thread[0].id" :thread="thread" class="full-width" />
|
||||
<Thread
|
||||
v-for="thread in visible"
|
||||
:key="thread[0].id"
|
||||
:thread="thread"
|
||||
class="full-width"
|
||||
/>
|
||||
|
||||
<ListPlaceholder :count="visible.length" :loading="loading" />
|
||||
|
||||
<AsyncLoadButton
|
||||
v-if="visible.length"
|
||||
:load-fn="loadOlder"
|
||||
autoload
|
||||
/>
|
||||
<AsyncLoadButton v-if="visible.length" :load-fn="loadOlder" autoload />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -24,8 +25,8 @@
|
||||
import AsyncLoadButton from 'components/AsyncLoadButton.vue'
|
||||
import Thread from 'components/Post/Thread.vue'
|
||||
import ListPlaceholder from 'components/ListPlaceholder.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import DateUtils from 'src/utils/DateUtils'
|
||||
import Bots from 'src/utils/bots'
|
||||
|
||||
@ -39,13 +40,13 @@ export default {
|
||||
components: {
|
||||
ListPlaceholder,
|
||||
Thread,
|
||||
AsyncLoadButton
|
||||
AsyncLoadButton,
|
||||
},
|
||||
props: {
|
||||
feed: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -73,30 +74,31 @@ export default {
|
||||
},
|
||||
timestampOldest() {
|
||||
return this.visible[this.visible.length - 1]?.[0]?.createdAt
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
const filters = typeof this.feed.filters === 'function'
|
||||
? this.feed.filters()
|
||||
: this.feed.filters
|
||||
this.stream = this.nostr.stream(
|
||||
filters,
|
||||
{
|
||||
subId: `feed:${this.feed.name}`,
|
||||
timeout: 3000,
|
||||
}
|
||||
)
|
||||
this.stream.on('init', notes => {
|
||||
const data = typeof this.feed.data === 'function'
|
||||
? this.feed.data()
|
||||
: this.feed.data || []
|
||||
const filters =
|
||||
typeof this.feed.filters === 'function'
|
||||
? this.feed.filters()
|
||||
: this.feed.filters
|
||||
this.stream = this.nostr.stream(filters, {
|
||||
subId: `feed:${this.feed.name}`,
|
||||
timeout: 3000,
|
||||
})
|
||||
this.stream.on('init', (notes) => {
|
||||
const data =
|
||||
typeof this.feed.data === 'function'
|
||||
? this.feed.data()
|
||||
: this.feed.data || []
|
||||
const items = notes
|
||||
.concat(data)
|
||||
.filter(note => this.filterNote(note, this.feed.hideBots))
|
||||
.map(note => [note]) // TODO Single element thread
|
||||
.filter((note) => this.filterNote(note, this.feed.hideBots))
|
||||
.map((note) => [note]) // TODO Single element thread
|
||||
.sort(feedOrder)
|
||||
.filter((item, pos, array) => !pos || item[0].id !== array[pos - 1][0].id)
|
||||
.filter(
|
||||
(item, pos, array) => !pos || item[0].id !== array[pos - 1][0].id
|
||||
)
|
||||
|
||||
this.visible = items.slice(0, MAX_ITEMS_VISIBLE)
|
||||
this.loading = false
|
||||
@ -104,14 +106,16 @@ export default {
|
||||
this.$emit('load', this.feed)
|
||||
|
||||
// Wait a bit before showing the first unreads
|
||||
setTimeout(() => this.recentlyLoaded = false, 5000)
|
||||
setTimeout(() => (this.recentlyLoaded = false), 5000)
|
||||
})
|
||||
this.stream.on('update', note => {
|
||||
this.stream.on('update', (note) => {
|
||||
if (!this.filterNote(note, this.feed.hideBots)) return
|
||||
if (note.createdAt >= this.timestampNewest) {
|
||||
this.newer.push([note]) // TODO Single element thread
|
||||
} else if (note.createdAt >= this.timestampOldest) {
|
||||
const idx = this.visible.findIndex(thread => thread[0].createdAt >= note.createdAt)
|
||||
const idx = this.visible.findIndex(
|
||||
(thread) => thread[0].createdAt >= note.createdAt
|
||||
)
|
||||
this.visible.splice(idx, 0, [note]) // TODO Single element thread
|
||||
}
|
||||
})
|
||||
@ -138,17 +142,18 @@ export default {
|
||||
|
||||
// Wait a bit before showing unreads again
|
||||
this.recentlyLoaded = true
|
||||
setTimeout(() => this.recentlyLoaded = false, 5000)
|
||||
setTimeout(() => (this.recentlyLoaded = false), 5000)
|
||||
|
||||
return true
|
||||
},
|
||||
async loadOlder() {
|
||||
const feedFilters = typeof this.feed.filters === 'function'
|
||||
? this.feed.filters()
|
||||
: this.feed.filters
|
||||
const feedFilters =
|
||||
typeof this.feed.filters === 'function'
|
||||
? this.feed.filters()
|
||||
: this.feed.filters
|
||||
|
||||
const until = this.timestampOldest || DateUtils.now()
|
||||
const filters = Object.assign({}, feedFilters, {until})
|
||||
const filters = Object.assign({}, feedFilters, { until })
|
||||
|
||||
if (this.older.length >= filters.limit) {
|
||||
const chunk = this.older.splice(0, filters.limit)
|
||||
@ -159,11 +164,13 @@ export default {
|
||||
// Remove any residual older items
|
||||
this.older = []
|
||||
|
||||
const older = await this.nostr.fetch(filters, {subId: `feed:${this.feed.name}-older`})
|
||||
const older = await this.nostr.fetch(filters, {
|
||||
subId: `feed:${this.feed.name}-older`,
|
||||
})
|
||||
const items = older
|
||||
.filter(note => note.createdAt <= until)
|
||||
.filter(note => this.filterNote(note, this.feed.hideBots))
|
||||
.map(note => [note]) // TODO Single element thread
|
||||
.filter((note) => note.createdAt <= until)
|
||||
.filter((note) => this.filterNote(note, this.feed.hideBots))
|
||||
.map((note) => [note]) // TODO Single element thread
|
||||
.sort(feedOrder)
|
||||
|
||||
// TODO Deduplicate feed items
|
||||
@ -183,7 +190,7 @@ export default {
|
||||
},
|
||||
unmounted() {
|
||||
if (this.stream) this.stream.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="!count" class="list-placeholder">
|
||||
<q-spinner v-if="loading" size="sm" />
|
||||
<p v-else>{{ label }}</p>
|
||||
<p v-else>{{ $t(label) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -19,9 +19,9 @@ export default {
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Nothing here',
|
||||
}
|
||||
}
|
||||
default: 'Nothing here.',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -8,14 +8,16 @@
|
||||
</div>
|
||||
<div v-for="(route, i) in items" :key="i">
|
||||
<MenuItem
|
||||
v-if="!hideItemsRequiringSignIn || !route.signInRequired || app.isSignedIn"
|
||||
v-if="
|
||||
!hideItemsRequiringSignIn || !route.signInRequired || app.isSignedIn
|
||||
"
|
||||
:icon="route.name.toLowerCase()"
|
||||
:to="route.path"
|
||||
:enabled="route.enabled !== false"
|
||||
:indicator="route.indicator && route.indicator()"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
{{ route.name }}
|
||||
{{ $t(route.name) }}
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem
|
||||
@ -25,14 +27,14 @@
|
||||
:enabled="app.isSignedIn"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
Profile
|
||||
{{ $t("Profile") }}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="settings"
|
||||
to="/settings"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
Settings
|
||||
{{ $t("Settings") }}
|
||||
</MenuItem>
|
||||
|
||||
<div
|
||||
@ -40,7 +42,7 @@
|
||||
class="menu-post-button"
|
||||
@click="createPost"
|
||||
>
|
||||
<span class="label">Post</span>
|
||||
<span class="label">{{ $t("Post") }}</span>
|
||||
<BaseIcon class="icon" icon="pen" />
|
||||
</div>
|
||||
</div>
|
||||
@ -49,18 +51,15 @@
|
||||
<ProfilePopup v-if="app.isSignedIn" />
|
||||
<div v-else class="sign-in" @click="signIn">
|
||||
<q-icon class="icon" name="login" size="sm" />
|
||||
<div class="label">Log in</div>
|
||||
<div class="label">{{ $t("Log in") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mobile-close-menu-button"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
<div class="mobile-close-menu-button" @click="$emit('mobile-menu-close')">
|
||||
<div class="icon">
|
||||
<BaseIcon icon="left" />
|
||||
</div>
|
||||
<span>Close</span>
|
||||
<span>{{ $t("Close") }}</span>
|
||||
</div>
|
||||
</menu>
|
||||
</template>
|
||||
@ -70,9 +69,9 @@ import MenuItem from 'components/MainMenu/MenuItem.vue'
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import ProfilePopup from 'components/MainMenu/ProfilePopup'
|
||||
import Logo from 'components/Logo.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
|
||||
import {hexToBech32} from 'src/utils/utils'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { MENU_ITEMS } from 'components/MainMenu/constants.js'
|
||||
import { hexToBech32 } from 'src/utils/utils'
|
||||
|
||||
export default {
|
||||
name: 'MainMenu',
|
||||
@ -80,13 +79,13 @@ export default {
|
||||
Logo,
|
||||
MenuItem,
|
||||
BaseIcon,
|
||||
ProfilePopup
|
||||
ProfilePopup,
|
||||
},
|
||||
props: {
|
||||
hideItemsRequiringSignIn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ['mobile-menu-close'],
|
||||
data() {
|
||||
@ -108,8 +107,8 @@ export default {
|
||||
this.$emit('mobile-menu-close')
|
||||
this.app.signIn()
|
||||
},
|
||||
hexToBech32
|
||||
}
|
||||
hexToBech32,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -134,7 +133,8 @@ menu {
|
||||
}
|
||||
&-logo {
|
||||
margin: 1rem 0;
|
||||
svg, img {
|
||||
svg,
|
||||
img {
|
||||
display: block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
|
@ -9,7 +9,7 @@
|
||||
<base-icon :icon="item.icon" />
|
||||
</div>
|
||||
<div class="content">
|
||||
{{ item.name }}
|
||||
{{ $t(item.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,11 +15,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-menu :offset="[0, 20]" target=".menu-profile" class="menu-profile-popup" >
|
||||
<q-menu :offset="[0, 20]" target=".menu-profile" class="menu-profile-popup">
|
||||
<div>
|
||||
<div v-for="(_, pk) in settings.accounts" :key="pk" class="popup-header" @click="app.switchAccount(pk)" v-close-popup>
|
||||
<div
|
||||
v-for="(_, pk) in settings.accounts"
|
||||
:key="pk"
|
||||
class="popup-header"
|
||||
@click="app.switchAccount(pk)"
|
||||
v-close-popup
|
||||
>
|
||||
<div class="sidebar-profile-pic">
|
||||
<UserAvatar :pubkey="pk" :clickable="false"/>
|
||||
<UserAvatar :pubkey="pk" :clickable="false" />
|
||||
</div>
|
||||
<div class="menu-profile-items">
|
||||
<div class="profile-info">
|
||||
@ -32,18 +38,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="popup-spacing">
|
||||
<hr class="popup-spacing" />
|
||||
<div class="popup-body">
|
||||
<div class="popup-body-item" @click="app.signIn()" v-close-popup>
|
||||
<p>Add an account</p>
|
||||
<p>{{ $t("Add an account") }}</p>
|
||||
</div>
|
||||
<hr class="popup-spacing">
|
||||
<hr class="popup-spacing" />
|
||||
<div
|
||||
class="popup-body-item"
|
||||
@click="$refs.logout.show()"
|
||||
v-close-popup
|
||||
>
|
||||
<p>Logout from <span><UserName :pubkey="pubkey" /></span></p>
|
||||
<p>
|
||||
{{ $t("Logout from") }}
|
||||
<span><UserName :pubkey="pubkey" /></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -57,8 +66,8 @@ import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
import LogoutDialog from 'components/User/LogoutDialog.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
|
||||
export default {
|
||||
name: 'ProfilePopup',
|
||||
@ -66,7 +75,7 @@ export default {
|
||||
LogoutDialog,
|
||||
UserName,
|
||||
UserAvatar,
|
||||
BaseIcon
|
||||
BaseIcon,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -80,7 +89,7 @@ export default {
|
||||
},
|
||||
accounts() {
|
||||
return this.settings.accounts
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -94,7 +103,7 @@ export default {
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: 120ms ease-in-out;
|
||||
@ -207,10 +216,9 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.menu-profile {
|
||||
padding: .5rem;
|
||||
padding: 0.5rem;
|
||||
margin: 0 auto 1rem auto;
|
||||
&-wrapper {
|
||||
padding: 0 1rem;
|
||||
|
@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<PostRenderer v-if="note" :note="note" />
|
||||
<span v-else-if="!decryptFailed" class="click-to-decrypt" @click="clickToDecrypt && decrypt()">Click to decrypt</span>
|
||||
<span v-else class="decrypt-failed" @click="decrypt">Decryption failed</span>
|
||||
<span
|
||||
v-else-if="!decryptFailed"
|
||||
class="click-to-decrypt"
|
||||
@click="clickToDecrypt && decrypt()"
|
||||
>
|
||||
{{ $t("Click to decrypt") }}
|
||||
</span>
|
||||
<span v-else class="decrypt-failed" @click="decrypt">
|
||||
{{ $t("Decryption failed") }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
|
||||
import Note from 'src/nostr/model/Note'
|
||||
|
||||
export default {
|
||||
name: 'EncryptedMessage',
|
||||
components: {PostRenderer},
|
||||
components: { PostRenderer },
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
@ -20,7 +28,7 @@ export default {
|
||||
clickToDecrypt: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -38,17 +46,21 @@ export default {
|
||||
const note = new Note(this.message.id, this.message)
|
||||
note.content = this.message.plaintext
|
||||
return note
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async decrypt() {
|
||||
if (this.message.plaintext) return
|
||||
try {
|
||||
const messageId = this.message.id
|
||||
const counterparty = this.message.author === this.app.myPubkey
|
||||
? this.message.recipient
|
||||
: this.message.author
|
||||
const plaintext = await this.app.decryptMessage(counterparty, this.message.content)
|
||||
const counterparty =
|
||||
this.message.author === this.app.myPubkey
|
||||
? this.message.recipient
|
||||
: this.message.author
|
||||
const plaintext = await this.app.decryptMessage(
|
||||
counterparty,
|
||||
this.message.content
|
||||
)
|
||||
// The message can change while we are decrypting it, so we need to make sure not to cache the wrong message.
|
||||
if (this.message.id === messageId) {
|
||||
this.message.cachePlaintext(plaintext)
|
||||
@ -57,7 +69,7 @@ export default {
|
||||
console.error('Failed to decrypt message', e)
|
||||
this.decryptFailed = true
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
if (this.app.activeAccount.canDecrypt()) {
|
||||
@ -69,8 +81,8 @@ export default {
|
||||
if (this.app.activeAccount.canDecrypt()) {
|
||||
await this.decrypt()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -14,7 +14,7 @@
|
||||
<div class="inline-controls-item">
|
||||
<BaseIcon icon="emoji" />
|
||||
<q-menu ref="menuEmojiPicker">
|
||||
<EmojiPicker @select="onEmojiSelected"/>
|
||||
<EmojiPicker @select="onEmojiSelected" />
|
||||
</q-menu>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,9 +38,10 @@
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import EmojiPicker from 'components/CreatePost/EmojiPicker.vue'
|
||||
import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'MessageEditor',
|
||||
@ -61,7 +62,7 @@ export default {
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ['publish'],
|
||||
data() {
|
||||
@ -95,10 +96,17 @@ export default {
|
||||
async publishMessage() {
|
||||
this.publishing = true
|
||||
|
||||
const ciphertext = await this.app.encryptMessage(this.recipient, this.content)
|
||||
const ciphertext = await this.app.encryptMessage(
|
||||
this.recipient,
|
||||
this.content
|
||||
)
|
||||
if (!ciphertext) return
|
||||
const event = EventBuilder.message(this.app.myPubkey, this.recipient, ciphertext).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
const event = EventBuilder.message(
|
||||
this.app.myPubkey,
|
||||
this.recipient,
|
||||
ciphertext
|
||||
).build()
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
|
||||
if (await this.nostr.publish(event)) {
|
||||
this.reset()
|
||||
@ -106,8 +114,8 @@ export default {
|
||||
this.$emit('publish', event)
|
||||
} else {
|
||||
this.$q.notify({
|
||||
message: `Failed to send message`,
|
||||
color: 'negative'
|
||||
message: $t(`Failed to send message`),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
|
||||
@ -118,7 +126,7 @@ export default {
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(this.focus.bind(this))
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -136,7 +144,7 @@ export default {
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
padding: 12px 36px 12px 1rem;
|
||||
margin-right: .5rem;
|
||||
margin-right: 0.5rem;
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -164,11 +172,11 @@ export default {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
cursor:pointer;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
svg {
|
||||
width: 100%;
|
||||
fill: $color-primary
|
||||
fill: $color-primary;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
|
@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="page-header" :class="{dense}">
|
||||
<div
|
||||
v-if="backButton"
|
||||
class="back-button"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<div class="page-header" :class="{ dense }">
|
||||
<div v-if="backButton" class="back-button" @click="$router.go(-1)">
|
||||
<base-icon icon="back" />
|
||||
</div>
|
||||
<div :class="{'profile-info': !!subline}">
|
||||
<div :class="{ 'profile-info': !!subline }">
|
||||
<slot>
|
||||
<h2>{{ title || titleFromRoute() || 'Home' }}</h2>
|
||||
<h2>{{ $t(title || titleFromRoute() || "Home") }}</h2>
|
||||
<span v-if="subline">{{ subline }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
@ -31,7 +27,7 @@ export default defineComponent({
|
||||
name: 'PageHeader',
|
||||
components: {
|
||||
Logo,
|
||||
BaseIcon
|
||||
BaseIcon,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
@ -53,14 +49,14 @@ export default defineComponent({
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
titleFromRoute() {
|
||||
const route = this.$route.name?.toLowerCase()
|
||||
return route?.charAt(0).toUpperCase() + route?.substring(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -116,14 +112,14 @@ export default defineComponent({
|
||||
}
|
||||
&.dense {
|
||||
.back-button {
|
||||
margin-right: .5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.page-header {
|
||||
padding: .4rem 1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
.logo {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
@ -14,7 +14,7 @@
|
||||
<div class="post-content">
|
||||
<div class="post-content-header">
|
||||
<p v-if="note.hasAncestor()" class="in-reply-to">
|
||||
Replying to
|
||||
{{ $t("Replying to") }}
|
||||
<a @click.stop="goToProfile(ancestor?.author)">
|
||||
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
|
||||
</a>
|
||||
@ -32,7 +32,11 @@
|
||||
<span>{{ formatDate(note.createdAt) }}</span>
|
||||
</p>
|
||||
<div class="post-content-actions">
|
||||
<PostActions :note="note" flavor="hero" @comment="$refs.editor.focus()" />
|
||||
<PostActions
|
||||
:note="note"
|
||||
flavor="hero"
|
||||
@comment="$refs.editor.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -41,7 +45,7 @@
|
||||
:ancestor="note"
|
||||
ref="editor"
|
||||
compact
|
||||
placeholder="Post your reply"
|
||||
:placeholder="$t('Post your reply')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -52,8 +56,8 @@ import UserName from 'components/User/UserName.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
|
||||
import PostEditor from 'components/CreatePost/PostEditor.vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
import DateUtils from 'src/utils/DateUtils'
|
||||
import PostActions from 'components/Post/PostActions.vue'
|
||||
@ -71,7 +75,7 @@ export default {
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
connector: {
|
||||
type: Boolean,
|
||||
@ -94,7 +98,7 @@ export default {
|
||||
methods: {
|
||||
formatDate: DateUtils.formatDate,
|
||||
formatTime: DateUtils.formatTime,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -169,10 +173,10 @@ export default {
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.post{
|
||||
.post {
|
||||
&-content {
|
||||
&-header {
|
||||
span{
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
.created-at {
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="post"
|
||||
:class="{clickable}"
|
||||
:class="{ clickable }"
|
||||
@click.stop="clickable && goToThread(note.id)"
|
||||
>
|
||||
<div class="post-author">
|
||||
@ -21,7 +21,7 @@
|
||||
<span class="created-at">{{ createdAt }}</span>
|
||||
</p>
|
||||
<p v-if="note.hasAncestor()" class="in-reply-to">
|
||||
Replying to
|
||||
{{ $t("Replying to") }}
|
||||
<a @click.stop="goToProfile(ancestor?.author)">
|
||||
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
|
||||
</a>
|
||||
@ -42,8 +42,8 @@ import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
import PostRenderer from 'components/Post/Renderer/PostRenderer.vue'
|
||||
import PostActions from 'components/Post/PostActions.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
import DateUtils from 'src/utils/DateUtils'
|
||||
|
||||
@ -59,7 +59,7 @@ export default {
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
connectorTop: {
|
||||
type: Boolean,
|
||||
@ -112,14 +112,18 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const updateInterval = DateUtils.now() - this.note.createdAt >= 3600 // 1h
|
||||
? 3600 // 1h
|
||||
: 60 // 1m
|
||||
this.refreshTimer = setInterval(() => this.refreshCounter++, updateInterval * 1000)
|
||||
const updateInterval =
|
||||
DateUtils.now() - this.note.createdAt >= 3600 // 1h
|
||||
? 3600 // 1h
|
||||
: 60 // 1m
|
||||
this.refreshTimer = setInterval(
|
||||
() => this.refreshCounter++,
|
||||
updateInterval * 1000
|
||||
)
|
||||
},
|
||||
unmounted() {
|
||||
clearInterval(this.refreshTimer)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -161,7 +165,7 @@ export default {
|
||||
}
|
||||
&-content {
|
||||
margin-left: 12px;
|
||||
padding: 1rem 0 .4rem;
|
||||
padding: 1rem 0 0.4rem;
|
||||
flex-grow: 1;
|
||||
max-width: 570px;
|
||||
&-header {
|
||||
@ -210,7 +214,7 @@ export default {
|
||||
}
|
||||
&-body {
|
||||
color: #fff;
|
||||
margin-bottom: .5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&-actions {
|
||||
}
|
||||
@ -222,7 +226,7 @@ export default {
|
||||
&-content {
|
||||
max-width: calc(100% - 48px - 1rem);
|
||||
&-body {
|
||||
margin-bottom: .5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,29 +2,30 @@
|
||||
<div class="post-actions" :class="flavor">
|
||||
<div class="action-item comment" @click.stop="comment">
|
||||
<BaseIcon icon="comment" />
|
||||
<span>{{ stats.comments || '' }}</span>
|
||||
<span>{{ stats.comments || "" }}</span>
|
||||
</div>
|
||||
<div class="action-item repost" @click.stop="repost">
|
||||
<BaseIcon icon="repost" />
|
||||
<span>{{ stats.shares || '' }}</span>
|
||||
<span>{{ stats.shares || "" }}</span>
|
||||
</div>
|
||||
<div class="action-item like" :class="{active: liked}" @click.stop="like">
|
||||
<div class="action-item like" :class="{ active: liked }" @click.stop="like">
|
||||
<BaseIcon :icon="liked ? 'like_filled' : 'like'" />
|
||||
<span>{{ stats.reactions || '' }}</span>
|
||||
<span>{{ stats.reactions || "" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useStatStore} from 'src/nostr/store/StatStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useStatStore } from 'src/nostr/store/StatStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'PostActions',
|
||||
components: {BaseIcon},
|
||||
components: { BaseIcon },
|
||||
emits: ['comment', 'repost'],
|
||||
props: {
|
||||
note: {
|
||||
@ -34,13 +35,13 @@ export default {
|
||||
flavor: {
|
||||
type: String,
|
||||
default: 'list',
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
stat: useStatStore(),
|
||||
nostr: useNostrStore()
|
||||
nostr: useNostrStore(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -57,7 +58,7 @@ export default {
|
||||
methods: {
|
||||
comment() {
|
||||
if (this.flavor === 'list') {
|
||||
this.app.createPost({ancestor: this.note.id})
|
||||
this.app.createPost({ ancestor: this.note.id })
|
||||
} else {
|
||||
this.$emit('comment')
|
||||
}
|
||||
@ -75,21 +76,21 @@ export default {
|
||||
},
|
||||
async publishLike() {
|
||||
const event = EventBuilder.reaction(this.note, this.app.myPubkey).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
if (!await this.nostr.publish(event)) {
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
if (!(await this.nostr.publish(event))) {
|
||||
this.$q.notify({
|
||||
message: 'Failed to publish reaction',
|
||||
message: $t('Failed to publish reaction'),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
},
|
||||
async deleteLike() {
|
||||
const ids = this.ourReactions.map(r => r.id)
|
||||
const ids = this.ourReactions.map((r) => r.id)
|
||||
const event = EventBuilder.delete(this.app.myPubkey, ids).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
if (!await this.nostr.publish(event)) {
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
if (!(await this.nostr.publish(event))) {
|
||||
this.$q.notify({
|
||||
message: 'Failed to delete reaction',
|
||||
message: $t('Failed to delete reaction'),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
@ -109,7 +110,7 @@ export default {
|
||||
max-width: 490px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
padding: .5rem 0 .5rem 16px;
|
||||
padding: 0.5rem 0 0.5rem 16px;
|
||||
&.list {
|
||||
width: calc(100% + 9px);
|
||||
margin-left: -9px;
|
||||
@ -140,7 +141,8 @@ export default {
|
||||
span {
|
||||
color: $color-light-gray;
|
||||
}
|
||||
&.active, &:hover {
|
||||
&.active,
|
||||
&:hover {
|
||||
&.comment {
|
||||
svg {
|
||||
fill: $post-action-blue;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative-position">
|
||||
<div class="searchbox" :class="{focused}">
|
||||
<div class="searchbox" :class="{ focused }">
|
||||
<div class="searchbox-wrapper">
|
||||
<div class="searchbox-icon">
|
||||
<BaseIcon icon="search" />
|
||||
@ -11,12 +11,12 @@
|
||||
v-model="query"
|
||||
ref="input"
|
||||
type="text"
|
||||
placeholder="Search profiles"
|
||||
:placeholder="$t('Search profiles')"
|
||||
@focus="toggleFocus"
|
||||
@blur="toggleFocus"
|
||||
@keyup="search"
|
||||
@keyup.esc="$refs.input.blur()"
|
||||
>
|
||||
/>
|
||||
</q-form>
|
||||
</div>
|
||||
</div>
|
||||
@ -70,7 +70,10 @@ export default {
|
||||
},
|
||||
async search() {
|
||||
if (this.query) {
|
||||
this.results = (await this.provider.queryProfiles(this.query)).slice(0, 200)
|
||||
this.results = (await this.provider.queryProfiles(this.query)).slice(
|
||||
0,
|
||||
200
|
||||
)
|
||||
} else {
|
||||
this.results = []
|
||||
}
|
||||
@ -80,7 +83,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'assets/theme/colors.scss';
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.searchbox {
|
||||
height: 50px;
|
||||
@ -126,9 +129,9 @@ export default {
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
background-color: $color-bg;
|
||||
border-radius: .5rem;
|
||||
border-radius: 0.5rem;
|
||||
z-index: 600;
|
||||
margin-top: -.75rem;
|
||||
margin-top: -0.75rem;
|
||||
box-shadow: $shadow-white;
|
||||
overflow-y: scroll;
|
||||
scrollbar-color: transparent transparent;
|
||||
@ -136,10 +139,12 @@ export default {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb { /* Foreground */
|
||||
&::-webkit-scrollbar-thumb {
|
||||
/* Foreground */
|
||||
background: $color-dark-gray;
|
||||
}
|
||||
&::-webkit-scrollbar-track { /* Background */
|
||||
&::-webkit-scrollbar-track {
|
||||
/* Background */
|
||||
background: transparent;
|
||||
}
|
||||
&-item {
|
||||
@ -152,7 +157,7 @@ export default {
|
||||
}
|
||||
.query-example {
|
||||
color: $color-light-gray;
|
||||
font-size: .95rem;
|
||||
font-size: 0.95rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +1,61 @@
|
||||
<template>
|
||||
<q-form v-if="app.isSignedIn" class="profile-settings" @submit.stop="updateProfile">
|
||||
<h3>Profile</h3>
|
||||
<q-form
|
||||
v-if="app.isSignedIn"
|
||||
class="profile-settings"
|
||||
@submit.stop="updateProfile"
|
||||
>
|
||||
<h3>{{ $t("Profile") }}</h3>
|
||||
<div class="input">
|
||||
<q-input v-model="name" label="Name" maxlength="64" dense />
|
||||
<q-input v-model="name" :label="$t('Name')" maxlength="64" dense />
|
||||
</div>
|
||||
<div class="input">
|
||||
<q-input v-model="about" label="About" maxlength="150" autogrow dense />
|
||||
<q-input
|
||||
v-model="about"
|
||||
:label="$t('About')"
|
||||
maxlength="150"
|
||||
autogrow
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
<div class="input">
|
||||
<q-input v-model="picture" label="Picture URL" dense />
|
||||
<img v-if="picture" :src="picture" class="picture-preview" loading="lazy" />
|
||||
<q-input v-model="picture" :label="$t('Picture URL')" dense />
|
||||
<img
|
||||
v-if="picture"
|
||||
:src="picture"
|
||||
class="picture-preview"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="input">
|
||||
<q-input v-model="nip05" label="NIP05 Identifier" dense />
|
||||
<q-icon v-if="verified" name="verified" class="nip05-verified" size="sm" />
|
||||
<q-input v-model="nip05" :label="$t('NIP05 Identifier')" dense />
|
||||
<q-icon
|
||||
v-if="verified"
|
||||
name="verified"
|
||||
class="nip05-verified"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" :disabled="!changed" class="btn btn-sm btn-primary">Save</button>
|
||||
<button class="btn btn-sm" :disabled="!changed" @click="setDataFromProfile">Reset</button>
|
||||
<button type="submit" :disabled="!changed" class="btn btn-sm btn-primary">
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="!changed"
|
||||
@click="setDataFromProfile"
|
||||
>
|
||||
{{ $t("Reset") }}
|
||||
</button>
|
||||
</div>
|
||||
</q-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import Nip05 from 'src/utils/Nip05'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'ProfileSettings',
|
||||
@ -55,18 +84,20 @@ export default {
|
||||
return this.nostr.getProfile(this.pubkey)
|
||||
},
|
||||
changed() {
|
||||
return this.name !== (this.profile?.name || '')
|
||||
|| this.about !== (this.profile?.about || '')
|
||||
|| this.picture !== (this.profile?.picture || '')
|
||||
|| this.nip05 !== (this.profile?.nip05?.url || '')
|
||||
return (
|
||||
this.name !== (this.profile?.name || '') ||
|
||||
this.about !== (this.profile?.about || '') ||
|
||||
this.picture !== (this.profile?.picture || '') ||
|
||||
this.nip05 !== (this.profile?.nip05?.url || '')
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setDataFromProfile() {
|
||||
this.name = (this.profile?.name || '')
|
||||
this.about = (this.profile?.about || '')
|
||||
this.picture = (this.profile?.picture || '')
|
||||
this.nip05 = (this.profile?.nip05.url || '')
|
||||
this.name = this.profile?.name || ''
|
||||
this.about = this.profile?.about || ''
|
||||
this.picture = this.profile?.picture || ''
|
||||
this.nip05 = this.profile?.nip05.url || ''
|
||||
this.verified = this.profile?.nip05.verified
|
||||
},
|
||||
async updateProfile() {
|
||||
@ -77,11 +108,11 @@ export default {
|
||||
nip05: this.nip05 || undefined,
|
||||
}
|
||||
const event = EventBuilder.metadata(this.pubkey, metadata).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
if (!await this.nostr.publish(event)) {
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
if (!(await this.nostr.publish(event))) {
|
||||
this.$q.notify({
|
||||
message: 'Failed to update profile',
|
||||
color: 'negative'
|
||||
message: $t('Failed to update profile'),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
},
|
||||
@ -92,11 +123,11 @@ export default {
|
||||
},
|
||||
async nip05() {
|
||||
this.verified = await Nip05.verify(this.pubkey, this.nip05)
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDataFromProfile()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -134,7 +165,7 @@ export default {
|
||||
font-weight: 600;
|
||||
}
|
||||
button + button {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -145,11 +176,12 @@ export default {
|
||||
.profile-settings .input {
|
||||
.q-field__label {
|
||||
color: $color-light-gray;
|
||||
margin: 0 .5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
color: #fff;
|
||||
padding: 0 .5rem;
|
||||
padding: 0 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.q-field__control {
|
||||
|
@ -1,14 +1,32 @@
|
||||
<template>
|
||||
<div class="relay-settings">
|
||||
<h3>Relays</h3>
|
||||
<h3>{{ $t("Relays") }}</h3>
|
||||
<div v-for="relay in settings.relays" :key="relay" class="relay">
|
||||
<span class="relay-url">{{ relay }}</span>
|
||||
<!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />-->
|
||||
<q-btn icon="delete_outline" size="sm" class="btn-icon" flat round @click="removeRelay(relay)" />
|
||||
<!-- <q-icon v-if="isConnected(relay)" icon="fiber_manual_record" size="sm" class="connected" />-->
|
||||
|
||||
<q-btn
|
||||
icon="delete_outline"
|
||||
size="sm"
|
||||
class="btn-icon"
|
||||
flat
|
||||
round
|
||||
@click="removeRelay(relay)"
|
||||
>
|
||||
<q-tooltip>{{ $t("Delete relay") }}</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-form class="add-relay" @submit.stop="addRelay">
|
||||
<q-input v-model="newRelayUrl" label="Add a relay" dense />
|
||||
<q-btn type="submit" icon="add_circle_outline" size="sm" flat round class="btn-icon" />
|
||||
<q-input v-model="newRelayUrl" :label="$t('Add relay')" dense />
|
||||
<q-btn
|
||||
type="submit"
|
||||
icon="add_circle_outline"
|
||||
size="sm"
|
||||
flat
|
||||
round
|
||||
:disabled="!newRelayUrl"
|
||||
class="btn-icon"
|
||||
/>
|
||||
</q-form>
|
||||
<div class="buttons">
|
||||
<button
|
||||
@ -16,15 +34,15 @@
|
||||
:disabled="!changed"
|
||||
@click="settings.restoreDefaultRelays()"
|
||||
>
|
||||
Restore defaults
|
||||
{{ $t("Restore defaults") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import {Notify} from 'quasar'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import { Notify } from 'quasar'
|
||||
// import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
@ -43,7 +61,7 @@ export default {
|
||||
computed: {
|
||||
changed() {
|
||||
return !this.settings.hasDefaultRelays()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addRelay() {
|
||||
@ -53,14 +71,14 @@ export default {
|
||||
} catch (e) {
|
||||
Notify.create({
|
||||
message: 'Invalid URL',
|
||||
color: 'negative'
|
||||
color: 'negative',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.protocol !== 'wss:') {
|
||||
Notify.create({
|
||||
message: 'Must be a wss:// URL',
|
||||
color: 'negative'
|
||||
color: 'negative',
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -71,7 +89,7 @@ export default {
|
||||
if (this.settings.hasRelay(href)) {
|
||||
Notify.create({
|
||||
message: 'Relay already exists',
|
||||
color: 'negative'
|
||||
color: 'negative',
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -84,7 +102,7 @@ export default {
|
||||
// isConnected(url) {
|
||||
// return this.nostr.client.isConnectedTo(url)
|
||||
// }
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -100,7 +118,7 @@ export default {
|
||||
.relay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .5rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: $border-dark;
|
||||
transition: 200ms ease;
|
||||
&:hover {
|
||||
@ -121,7 +139,7 @@ export default {
|
||||
}
|
||||
.btn-icon {
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
right: 0.5rem;
|
||||
top: 7px;
|
||||
}
|
||||
}
|
||||
@ -136,7 +154,7 @@ export default {
|
||||
font-weight: 600;
|
||||
}
|
||||
button + button {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,11 +165,11 @@ export default {
|
||||
.relay-settings .add-relay {
|
||||
.q-field__label {
|
||||
color: $color-light-gray;
|
||||
margin: 0 .5rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
input {
|
||||
color: #fff;
|
||||
padding: 0 .5rem;
|
||||
padding: 0 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.q-field__control {
|
||||
|
@ -2,11 +2,9 @@
|
||||
<div v-if="app.isSignedIn && contacts?.length" class="following">
|
||||
<div class="following-wrapper">
|
||||
<div class="following-header">
|
||||
<h3>Following</h3>
|
||||
<h3>{{ $t("Following") }}</h3>
|
||||
</div>
|
||||
<div
|
||||
class="following-body"
|
||||
>
|
||||
<div class="following-body">
|
||||
<UserCard
|
||||
v-for="contact in contacts"
|
||||
:key="contact.pubkey"
|
||||
@ -23,13 +21,13 @@
|
||||
|
||||
<script>
|
||||
import UserCard from 'components/User/UserCard.vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
|
||||
export default {
|
||||
name: 'FollowingBox',
|
||||
components: {UserCard},
|
||||
components: { UserCard },
|
||||
mixins: [routerMixin],
|
||||
setup() {
|
||||
return {
|
||||
@ -43,7 +41,7 @@ export default {
|
||||
},
|
||||
contacts() {
|
||||
return this.nostr.getContacts(this.pubkey)?.slice(0, 20)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -74,10 +72,12 @@ export default {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb { /* Foreground */
|
||||
&::-webkit-scrollbar-thumb {
|
||||
/* Foreground */
|
||||
background: $color-dark-gray;
|
||||
}
|
||||
&::-webkit-scrollbar-track { /* Background */
|
||||
&::-webkit-scrollbar-track {
|
||||
/* Background */
|
||||
background: transparent;
|
||||
}
|
||||
&-item {
|
||||
@ -104,7 +104,11 @@ export default {
|
||||
right: 0;
|
||||
height: 1.2rem;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
background: linear-gradient(180deg, rgba(29, 41, 53, 0), rgba(29, 41, 53, 1));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(29, 41, 53, 0),
|
||||
rgba(29, 41, 53, 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,23 +1,35 @@
|
||||
<template>
|
||||
<div class="welcome" v-if="!app.isSignedIn">
|
||||
<div class="welcome-header">
|
||||
<h3>New to Nostr?</h3>
|
||||
<h3>{{ $t("New to Nostr?") }}</h3>
|
||||
</div>
|
||||
<div class="welcome-content">
|
||||
<button v-if="nip07available" class="btn btn-primary" @click.stop="signInNip07()">Log in with Extension</button>
|
||||
<button class="btn" :class="{'btn-primary': !nip07available}" @click.stop="signUp">
|
||||
Create Account
|
||||
<button
|
||||
v-if="nip07available"
|
||||
class="btn btn-primary"
|
||||
@click.stop="signInNip07()"
|
||||
>
|
||||
{{ $t("Log in with Extension") }}
|
||||
</button>
|
||||
<button v-if="!nip07available" class="btn" @click.stop="signIn">Log in</button>
|
||||
<a v-else @click.stop="signIn">Log in with key</a>
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ 'btn-primary': !nip07available }"
|
||||
@click.stop="signUp"
|
||||
>
|
||||
{{ $t("Create Account") }}
|
||||
</button>
|
||||
<button v-if="!nip07available" class="btn" @click.stop="signIn">
|
||||
{{ $t("Log in") }}
|
||||
</button>
|
||||
<a v-else @click.stop="signIn"> {{ $t("Log in with key") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import Nip07 from 'src/utils/Nip07'
|
||||
|
||||
export default {
|
||||
@ -56,8 +68,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.nip07available = Nip07.isAvailable()
|
||||
setTimeout(() => this.nip07available = Nip07.isAvailable(), 300)
|
||||
}
|
||||
setTimeout(() => (this.nip07available = Nip07.isAvailable()), 300)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -82,7 +94,7 @@ export default {
|
||||
text-align: center;
|
||||
button {
|
||||
width: 100%;
|
||||
padding: .5rem;
|
||||
padding: 0.5rem;
|
||||
&:first-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@ -98,5 +110,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<q-dialog v-model="app.signInDialog.open" @before-show="updateFragment" @hide="onClose" ref="signInDialog">
|
||||
<q-dialog
|
||||
v-model="app.signInDialog.open"
|
||||
@before-show="updateFragment"
|
||||
@hide="onClose"
|
||||
ref="signInDialog"
|
||||
>
|
||||
<div class="sign-in-dialog">
|
||||
<q-btn
|
||||
v-if="showClose"
|
||||
@ -29,17 +34,39 @@
|
||||
<p class="prompt">
|
||||
{{ prompt }}
|
||||
</p>
|
||||
<button v-if="nip07available" class="btn btn-primary" @click.stop="signInNip07()">Log in with Extension</button>
|
||||
<button class="btn" :class="{'btn-primary': !nip07available}" @click.stop="fragment = 'sign-up'">
|
||||
Create Account
|
||||
<button
|
||||
v-if="nip07available"
|
||||
class="btn btn-primary"
|
||||
@click.stop="signInNip07()"
|
||||
>
|
||||
{{ $t("Log in with Extension") }}
|
||||
</button>
|
||||
<button v-if="!nip07available" class="btn" @click.stop="fragment = 'sign-in'">Log in</button>
|
||||
<a v-else @click.stop="fragment = 'sign-in'">Log in with key</a>
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ 'btn-primary': !nip07available }"
|
||||
@click.stop="fragment = 'sign-up'"
|
||||
>
|
||||
{{ $t("Create Account") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!nip07available"
|
||||
class="btn"
|
||||
@click.stop="fragment = 'sign-in'"
|
||||
>
|
||||
{{ $t("Log in") }}
|
||||
</button>
|
||||
<a v-else @click.stop="fragment = 'sign-in'">{{
|
||||
$t("Log in with key")
|
||||
}}</a>
|
||||
</div>
|
||||
|
||||
<SignUpForm v-if="fragment === 'sign-up'" @complete="onComplete" />
|
||||
<SignInForm v-if="fragment === 'sign-in'" @complete="onComplete"/>
|
||||
<SignInForm v-if="fragment === 'private-key'" @complete="onComplete" private-key-only />
|
||||
<SignInForm v-if="fragment === 'sign-in'" @complete="onComplete" />
|
||||
<SignInForm
|
||||
v-if="fragment === 'private-key'"
|
||||
@complete="onComplete"
|
||||
private-key-only
|
||||
/>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
@ -49,8 +76,8 @@ import Logo from 'components/Logo.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import SignUpForm from 'components/SignIn/SignUpForm.vue'
|
||||
import SignInForm from 'components/SignIn/SignInForm.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import Nip07 from 'src/utils/Nip07'
|
||||
|
||||
export default {
|
||||
@ -59,13 +86,13 @@ export default {
|
||||
Logo,
|
||||
UserAvatar,
|
||||
SignInForm,
|
||||
SignUpForm
|
||||
SignUpForm,
|
||||
},
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -89,7 +116,7 @@ export default {
|
||||
},
|
||||
nip07available() {
|
||||
return Nip07.isAvailable()
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
@ -99,7 +126,7 @@ export default {
|
||||
this.fragment = 'welcome'
|
||||
this.pubkey = null
|
||||
},
|
||||
onComplete({pubkey}) {
|
||||
onComplete({ pubkey }) {
|
||||
this.pubkey = pubkey
|
||||
this.$refs.signInDialog.hide()
|
||||
},
|
||||
@ -117,7 +144,7 @@ export default {
|
||||
this.settings.addAccount(account)
|
||||
this.app.switchAccount(pubkey)
|
||||
|
||||
this.onComplete({pubkey})
|
||||
this.onComplete({ pubkey })
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -137,8 +164,8 @@ export default {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: .5rem;
|
||||
left: .5rem;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
fill: #fff;
|
||||
}
|
||||
.logo {
|
||||
@ -178,5 +205,4 @@ export default {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -1,28 +1,30 @@
|
||||
<template>
|
||||
<div class="sign-in">
|
||||
<h3>{{ header }}</h3>
|
||||
<h3>{{ $t(header) }}</h3>
|
||||
<q-form @submit.stop="signIn">
|
||||
<label for="private-key">{{ prompt }}</label>
|
||||
<label for="private-key">{{ $t(prompt) }}</label>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="key"
|
||||
:placeholder="placeholder"
|
||||
:placeholder="$t(placeholder)"
|
||||
maxlength="63"
|
||||
:class="{
|
||||
valid: validKey,
|
||||
invalid: invalidKey,
|
||||
}"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validKey">{{ buttonLabel }}</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validKey">
|
||||
{{ $t(buttonLabel) }}
|
||||
</button>
|
||||
</q-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {decode as bech32decode} from 'bech32-buffer'
|
||||
import {bech32prefix, bech32ToHex} from 'src/utils/utils'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { decode as bech32decode } from 'bech32-buffer'
|
||||
import { bech32prefix, bech32ToHex } from 'src/utils/utils'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import { useAppStore } from 'stores/App'
|
||||
|
||||
export default {
|
||||
name: 'SignInForm',
|
||||
@ -31,7 +33,7 @@ export default {
|
||||
privateKeyOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -40,44 +42,35 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
header() {
|
||||
// TODO i18n
|
||||
return this.privateKeyOnly
|
||||
? 'Private key needed'
|
||||
: 'Log in'
|
||||
return this.privateKeyOnly ? 'Private key needed' : 'Log in'
|
||||
},
|
||||
prompt() {
|
||||
// TODO i18n
|
||||
return this.privateKeyOnly
|
||||
? 'Paste your private key to continue'
|
||||
: 'Paste your public or private key'
|
||||
},
|
||||
placeholder() {
|
||||
// TODO i18n
|
||||
return this.privateKeyOnly
|
||||
? 'nsec…'
|
||||
: 'npub… / nsec…'
|
||||
return this.privateKeyOnly ? 'nsec…' : 'npub… / nsec…'
|
||||
},
|
||||
buttonLabel() {
|
||||
// TODO i18n
|
||||
return this.privateKeyOnly
|
||||
? 'Continue'
|
||||
: 'Log in'
|
||||
return this.privateKeyOnly ? 'Continue' : 'Log in'
|
||||
},
|
||||
validKey() {
|
||||
return this.isValidKey(this.key)
|
||||
},
|
||||
invalidKey() {
|
||||
return this.key
|
||||
&& this.key.length >= 63
|
||||
&& !this.isValidKey(this.key)
|
||||
}
|
||||
return this.key && this.key.length >= 63 && !this.isValidKey(this.key)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isValidKey(str) {
|
||||
if (!str) return false
|
||||
try {
|
||||
const {data, prefix} = bech32decode(str.toLowerCase())
|
||||
return data.byteLength === 32 && ((prefix === 'npub' && !this.privateKeyOnly) || prefix === 'nsec')
|
||||
const { data, prefix } = bech32decode(str.toLowerCase())
|
||||
return (
|
||||
data.byteLength === 32 &&
|
||||
((prefix === 'npub' && !this.privateKeyOnly) || prefix === 'nsec')
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
@ -87,15 +80,15 @@ export default {
|
||||
|
||||
let opts
|
||||
if (bech32prefix(this.key) === 'npub') {
|
||||
opts = {pubkey: bech32ToHex(this.key)}
|
||||
opts = { pubkey: bech32ToHex(this.key) }
|
||||
} else {
|
||||
opts = {privkey: bech32ToHex(this.key)}
|
||||
opts = { privkey: bech32ToHex(this.key) }
|
||||
}
|
||||
|
||||
const account = useSettingsStore().addAccount(opts)
|
||||
useAppStore().switchAccount(account.pubkey)
|
||||
|
||||
this.$emit('complete', {pubkey: account.pubkey})
|
||||
this.$emit('complete', { pubkey: account.pubkey })
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
@ -1,20 +1,28 @@
|
||||
<template>
|
||||
<div class="sign-up">
|
||||
<h3>Create Account</h3>
|
||||
<h3>{{ $t("Create Account") }}</h3>
|
||||
<q-form @submit.stop="signUp">
|
||||
<label for="username">What's your name?</label>
|
||||
<input v-model="username" ref="input" id="username" autocomplete="false" />
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validUsername">Create</button>
|
||||
<label for="username">{{ $t("What's your name?") }}</label>
|
||||
<input
|
||||
v-model="username"
|
||||
ref="input"
|
||||
id="username"
|
||||
autocomplete="false"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validUsername">
|
||||
{{ $t("Create") }}
|
||||
</button>
|
||||
</q-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import {generatePrivateKey} from 'nostr-tools'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import { generatePrivateKey } from 'nostr-tools'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'SignUpForm',
|
||||
@ -36,28 +44,30 @@ export default {
|
||||
const privkey = generatePrivateKey()
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const account = settings.addAccount({privkey})
|
||||
const account = settings.addAccount({ privkey })
|
||||
const app = useAppStore()
|
||||
app.switchAccount(account.pubkey)
|
||||
|
||||
const event = EventBuilder.metadata(account.pubkey, {name: this.username}).build()
|
||||
const event = EventBuilder.metadata(account.pubkey, {
|
||||
name: this.username,
|
||||
}).build()
|
||||
await app.signEvent(event)
|
||||
if (await useNostrStore().publish(event)) {
|
||||
this.$emit('complete', {
|
||||
pubkey: account.pubkey,
|
||||
name: this.username
|
||||
name: this.username,
|
||||
})
|
||||
} else {
|
||||
this.$q.notify({
|
||||
message: 'Failed to create profile',
|
||||
message: $t('Failed to create profile'),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -2,17 +2,18 @@
|
||||
<button
|
||||
v-if="app.isSignedIn"
|
||||
class="btn btn-sm"
|
||||
:class="{'btn-primary': !isFollowing}"
|
||||
:class="{ 'btn-primary': !isFollowing }"
|
||||
@click="toggleFollow"
|
||||
>
|
||||
{{ isFollowing ? 'Unfollow' : 'Follow' }}
|
||||
{{ $t(isFollowing ? "Unfollow" : "Follow") }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import EventBuilder from 'src/nostr/EventBuilder'
|
||||
import { $t } from 'src/boot/i18n'
|
||||
|
||||
export default {
|
||||
name: 'FollowButton',
|
||||
@ -20,7 +21,7 @@ export default {
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -34,33 +35,39 @@ export default {
|
||||
},
|
||||
isFollowing() {
|
||||
// TODO improve
|
||||
return this.contacts?.length && this.contacts.find(contact => contact.pubkey === this.pubkey)
|
||||
return (
|
||||
this.contacts?.length &&
|
||||
this.contacts.find((contact) => contact.pubkey === this.pubkey)
|
||||
)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async updateContacts(contacts) {
|
||||
const event = EventBuilder.contacts(this.app.myPubkey, contacts.map(c => c.pubkey)).build()
|
||||
if (!await this.app.signEvent(event)) return
|
||||
if (!await this.nostr.publish(event)) {
|
||||
const event = EventBuilder.contacts(
|
||||
this.app.myPubkey,
|
||||
contacts.map((c) => c.pubkey)
|
||||
).build()
|
||||
if (!(await this.app.signEvent(event))) return
|
||||
if (!(await this.nostr.publish(event))) {
|
||||
this.$q.notify({
|
||||
message: 'Failed to update followers',
|
||||
message: $t('Failed to update followers'),
|
||||
color: 'negative',
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleFollow() {
|
||||
return this.isFollowing
|
||||
? this.unfollow()
|
||||
: this.follow()
|
||||
return this.isFollowing ? this.unfollow() : this.follow()
|
||||
},
|
||||
async follow() {
|
||||
const contacts = [].concat(this.contacts || []) // Clone array
|
||||
contacts.push({pubkey: this.pubkey})
|
||||
contacts.push({ pubkey: this.pubkey })
|
||||
await this.updateContacts(contacts)
|
||||
},
|
||||
async unfollow() {
|
||||
const contacts = [].concat(this.contacts || []) // Clone array
|
||||
const idx = contacts.findIndex(contact => contact.pubkey === this.pubkey)
|
||||
const idx = contacts.findIndex(
|
||||
(contact) => contact.pubkey === this.pubkey
|
||||
)
|
||||
contacts.splice(idx, 1)
|
||||
await this.updateContacts(contacts)
|
||||
},
|
||||
@ -69,5 +76,4 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
@ -1,22 +1,31 @@
|
||||
<template>
|
||||
<q-dialog v-model="dialogOpen">
|
||||
<div class="logout-dialog">
|
||||
<q-btn icon="close" size="md" class="icon" flat round v-close-popup/>
|
||||
|
||||
<h3>Log out from <UserName :pubkey="pubkey" /></h3>
|
||||
<p>
|
||||
Do you really want to log out from <UserName :pubkey="pubkey" />?
|
||||
</p>
|
||||
<q-btn icon="close" size="md" class="icon" flat round v-close-popup />
|
||||
<h3>
|
||||
{{ $t("Do you really want to log out from") }}
|
||||
<UserName :pubkey="pubkey" />?
|
||||
</h3>
|
||||
<p v-if="privateKey" class="warning">
|
||||
<span class="warning-icon"><q-icon name="warning" size="lg" /></span>
|
||||
<span class="warning-content">
|
||||
Make sure you have a backup of your private key! Otherwise it is impossible to log back in to your account.
|
||||
{{
|
||||
$t(
|
||||
"Make sure you have a backup of your private key! Otherwise it is impossible to log back in to your account."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<input v-if="privateKey" :value="hexToBech32(privateKey, 'nsec')" readonly />
|
||||
<input
|
||||
v-if="privateKey"
|
||||
:value="hexToBech32(privateKey, 'nsec')"
|
||||
readonly
|
||||
/>
|
||||
<div class="buttons">
|
||||
<button class="btn btn-sm btn-primary" @click="logout" v-close-popup>Log out</button>
|
||||
<button class="btn btn-sm" v-close-popup>Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" @click="logout" v-close-popup>
|
||||
{{ $t("Log out") }}
|
||||
</button>
|
||||
<button class="btn btn-sm" v-close-popup>{{ $t("Cancel") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</q-dialog>
|
||||
@ -24,14 +33,14 @@
|
||||
|
||||
<script>
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useSettingsStore} from 'stores/Settings'
|
||||
import {hexToBech32} from 'src/utils/utils'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useSettingsStore } from 'stores/Settings'
|
||||
import { hexToBech32 } from 'src/utils/utils'
|
||||
|
||||
export default {
|
||||
name: 'LogoutDialog',
|
||||
components: {
|
||||
UserName
|
||||
UserName,
|
||||
},
|
||||
props: {
|
||||
pubkey: {
|
||||
@ -51,7 +60,7 @@ export default {
|
||||
computed: {
|
||||
privateKey() {
|
||||
return useAppStore().activeAccount.privkey
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hexToBech32,
|
||||
@ -63,7 +72,7 @@ export default {
|
||||
},
|
||||
dismiss() {
|
||||
this.dialogOpen = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -83,17 +92,17 @@ export default {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: .5rem;
|
||||
left: .5rem;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 3rem;
|
||||
padding: 0 .5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
> p {
|
||||
padding: 0 .5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
|
@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<span v-if="verified" class="nip05-badge">
|
||||
<q-icon name="verified" :size="size" color="primary">
|
||||
<q-tooltip>NIP05 verified</q-tooltip>
|
||||
<q-tooltip>{{ $t("NIP05 verified") }}</q-tooltip>
|
||||
</q-icon>
|
||||
<span class="nip05-badge-text">{{ nip05 }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
name: 'Nip05Badge',
|
||||
@ -19,8 +19,8 @@ export default {
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '14px'
|
||||
}
|
||||
default: '14px',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
@ -40,18 +40,22 @@ export default {
|
||||
if (!this.profile?.nip05.url) return
|
||||
return this.profile.nip05.url
|
||||
.split('@')
|
||||
.filter(part => part !== '_' && part?.toLowerCase() !== this.profile.name?.toLowerCase())
|
||||
.filter(
|
||||
(part) =>
|
||||
part !== '_' &&
|
||||
part?.toLowerCase() !== this.profile.name?.toLowerCase()
|
||||
)
|
||||
.join('@')
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async profile() {
|
||||
this.verified = await this.profile?.isNip05Verified()
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.verified = await this.profile?.isNip05Verified()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -2,5 +2,6 @@
|
||||
// so you can safely delete all default props below
|
||||
|
||||
export default {
|
||||
'thread': 'Thread'
|
||||
thread: 'Thread',
|
||||
'Load {unread} unread': 'Load {unread} unread',
|
||||
}
|
93
src/i18n/es/index.js
Normal file
93
src/i18n/es/index.js
Normal 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?'
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import enUS from './en-US'
|
||||
import en from './en'
|
||||
import es from './es'
|
||||
|
||||
export default {
|
||||
'en-US': enUS
|
||||
en,
|
||||
es,
|
||||
}
|
||||
|
@ -10,10 +10,11 @@
|
||||
size="sm"
|
||||
class="feed-selector"
|
||||
:options="[
|
||||
{value: 'following', icon: 'group'},
|
||||
{value: 'global', icon: 'public'},
|
||||
{ value: 'following', icon: 'group', title: $t('Following') },
|
||||
{ value: 'global', icon: 'public', title: $t('Global') },
|
||||
]"
|
||||
/>
|
||||
>
|
||||
</q-btn-toggle>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@ -30,22 +31,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import PageHeader from 'components/PageHeader.vue'
|
||||
import PostEditor from 'components/CreatePost/PostEditor.vue'
|
||||
import Feed from 'components/Feed/Feed.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {EventKind} from 'src/nostr/model/Event'
|
||||
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { EventKind } from 'src/nostr/model/Event'
|
||||
import { NoteOrder, useNoteStore } from 'src/nostr/store/NoteStore'
|
||||
|
||||
const ZERO_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
const ZERO_PUBKEY =
|
||||
'0000000000000000000000000000000000000000000000000000000000000000'
|
||||
|
||||
const myContacts = () => {
|
||||
const app = useAppStore()
|
||||
const nostr = useNostrStore()
|
||||
const contacts = nostr.getContacts(app.myPubkey)
|
||||
return contacts?.map(contact => contact.pubkey).concat(app.myPubkey)
|
||||
return contacts?.map((contact) => contact.pubkey).concat(app.myPubkey)
|
||||
}
|
||||
|
||||
const Feeds = {
|
||||
@ -74,7 +76,9 @@ const Feeds = {
|
||||
let notes = []
|
||||
const store = useNoteStore()
|
||||
for (const author of authors) {
|
||||
notes = notes.concat(store.postsByAuthor(author, NoteOrder.CREATION_DATE_DESC))
|
||||
notes = notes.concat(
|
||||
store.postsByAuthor(author, NoteOrder.CREATION_DATE_DESC)
|
||||
)
|
||||
}
|
||||
return notes
|
||||
},
|
||||
@ -122,15 +126,14 @@ export default defineComponent({
|
||||
if (this.initialized) return
|
||||
if (!this.contacts) return
|
||||
this.initialized = true
|
||||
this.activeFeed = this.contacts?.length
|
||||
? 'following'
|
||||
: 'global'
|
||||
this.activeFeed = this.contacts?.length ? 'following' : 'global'
|
||||
},
|
||||
onFeedLoaded(feed) {
|
||||
if (this.activeFeed === 'following'
|
||||
&& feed?.name === this.activeFeed
|
||||
&& !this.contacts?.length
|
||||
&& !this.initialized
|
||||
if (
|
||||
this.activeFeed === 'following' &&
|
||||
feed?.name === this.activeFeed &&
|
||||
!this.contacts?.length &&
|
||||
!this.initialized
|
||||
) {
|
||||
this.activeFeed = 'global'
|
||||
this.initialized = true
|
||||
@ -145,7 +148,7 @@ export default defineComponent({
|
||||
},
|
||||
mounted() {
|
||||
this.initFeed()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -18,7 +18,8 @@
|
||||
:connector="ancestors?.length > 0"
|
||||
/>
|
||||
<div v-else style="padding-left: 1.5rem">
|
||||
<q-spinner size="sm" style="margin-right: .5rem"/> Loading...
|
||||
<q-spinner size="sm" style="margin-right: 0.5rem" />
|
||||
{{ $t("Loading...") }}
|
||||
</div>
|
||||
</q-item>
|
||||
|
||||
@ -28,18 +29,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="min-height: 80vh;" />
|
||||
<div style="min-height: 80vh" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import PageHeader from 'components/PageHeader.vue'
|
||||
import Thread from 'components/Post/Thread.vue'
|
||||
import HeroPost from 'components/Post/HeroPost.vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {NoteOrder} from 'src/nostr/store/NoteStore'
|
||||
import {bech32ToHex} from 'src/utils/utils'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { NoteOrder } from 'src/nostr/store/NoteStore'
|
||||
import { bech32ToHex } from 'src/utils/utils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ThreadPage',
|
||||
@ -50,7 +51,7 @@ export default defineComponent({
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
nostr: useNostrStore()
|
||||
nostr: useNostrStore(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -72,9 +73,7 @@ export default defineComponent({
|
||||
},
|
||||
rootId() {
|
||||
if (!this.noteLoaded) return
|
||||
return this.note.hasAncestor()
|
||||
? this.note.root()
|
||||
: this.note.id
|
||||
return this.note.hasAncestor() ? this.note.root() : this.note.id
|
||||
},
|
||||
root() {
|
||||
if (!this.rootId) return
|
||||
@ -89,7 +88,9 @@ export default defineComponent({
|
||||
const ancestors = this.allAncestors(this.note)
|
||||
// Sanity check
|
||||
if (ancestors.length > 0 && ancestors[0].id !== this.rootId) {
|
||||
console.error(`Invalid thread structure: expected root ${this.rootId} but found ${ancestors[0].id}`)
|
||||
console.error(
|
||||
`Invalid thread structure: expected root ${this.rootId} but found ${ancestors[0].id}`
|
||||
)
|
||||
// return
|
||||
}
|
||||
return this.collectPredecessors(ancestors, this.note)
|
||||
@ -118,13 +119,14 @@ export default defineComponent({
|
||||
if (!ancestors || !ancestors.length) return []
|
||||
|
||||
const ancestor = ancestors.pop()
|
||||
const replies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
|
||||
const targetIdx = replies.findIndex(reply => reply.id === target.id)
|
||||
const replies = this.nostr.getRepliesTo(
|
||||
ancestor.id,
|
||||
NoteOrder.CREATION_DATE_ASC
|
||||
)
|
||||
const targetIdx = replies.findIndex((reply) => reply.id === target.id)
|
||||
const predecessors = [ancestor].concat(replies.slice(0, targetIdx))
|
||||
|
||||
return this
|
||||
.collectPredecessors(ancestors, ancestor)
|
||||
.concat(predecessors)
|
||||
return this.collectPredecessors(ancestors, ancestor).concat(predecessors)
|
||||
},
|
||||
|
||||
collectChildren(target, ancestor) {
|
||||
@ -132,8 +134,13 @@ export default defineComponent({
|
||||
|
||||
// Get same-level successors
|
||||
if (ancestor) {
|
||||
const ancestorReplies = this.nostr.getRepliesTo(ancestor.id, NoteOrder.CREATION_DATE_ASC)
|
||||
const targetIdx = ancestorReplies.findIndex(reply => reply.id === target.id)
|
||||
const ancestorReplies = this.nostr.getRepliesTo(
|
||||
ancestor.id,
|
||||
NoteOrder.CREATION_DATE_ASC
|
||||
)
|
||||
const targetIdx = ancestorReplies.findIndex(
|
||||
(reply) => reply.id === target.id
|
||||
)
|
||||
const successors = ancestorReplies.slice(targetIdx + 1)
|
||||
if (successors.length) {
|
||||
children.push(successors)
|
||||
@ -141,7 +148,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// Get children of target
|
||||
const targetReplies = this.nostr.getRepliesTo(target.id, NoteOrder.CREATION_DATE_DESC)
|
||||
const targetReplies = this.nostr.getRepliesTo(
|
||||
target.id,
|
||||
NoteOrder.CREATION_DATE_DESC
|
||||
)
|
||||
for (const reply of targetReplies) {
|
||||
children.push(this.unrollLongest(reply))
|
||||
}
|
||||
@ -152,7 +162,10 @@ export default defineComponent({
|
||||
// Unrolls linear replies until first "fork"
|
||||
unrollLinear(root) {
|
||||
const thread = [root]
|
||||
let replies = this.nostr.getRepliesTo(root.id, NoteOrder.CREATION_DATE_ASC)
|
||||
let replies = this.nostr.getRepliesTo(
|
||||
root.id,
|
||||
NoteOrder.CREATION_DATE_ASC
|
||||
)
|
||||
while (replies.length === 1) {
|
||||
thread.push(replies[0])
|
||||
root = replies[0]
|
||||
@ -164,7 +177,10 @@ export default defineComponent({
|
||||
// Unrolls the longest thread in the subtree
|
||||
unrollLongest(root) {
|
||||
let threads = []
|
||||
let replies = this.nostr.getRepliesTo(root.id, NoteOrder.CREATION_DATE_ASC)
|
||||
let replies = this.nostr.getRepliesTo(
|
||||
root.id,
|
||||
NoteOrder.CREATION_DATE_ASC
|
||||
)
|
||||
for (const reply of replies) {
|
||||
threads.push(this.unrollLongest(reply))
|
||||
}
|
||||
@ -205,7 +221,7 @@ export default defineComponent({
|
||||
if (this.rootLoaded) {
|
||||
this.startStream()
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.startStream()
|
||||
@ -217,7 +233,7 @@ export default defineComponent({
|
||||
unmounted() {
|
||||
this.closeStream()
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -12,7 +12,8 @@
|
||||
/>
|
||||
<p v-if="!conversation?.length" class="placeholder">
|
||||
<template v-if="counterparty !== app.myPubkey">
|
||||
This is the beginning of your message history with <UserName :pubkey="counterparty" clickable />.
|
||||
{{ $t("This is the beginning of your message history with") }}
|
||||
<UserName :pubkey="counterparty" clickable />.
|
||||
</template>
|
||||
<template v-else>
|
||||
Keep private notes by sending messages to yourself.
|
||||
@ -21,7 +22,12 @@
|
||||
</div>
|
||||
|
||||
<div class="conversation-reply">
|
||||
<MessageEditor :recipient="counterparty" :placeholder="placeholder" @publish="onPublish" autofocus />
|
||||
<MessageEditor
|
||||
:recipient="counterparty"
|
||||
:placeholder="$t(placeholder)"
|
||||
@publish="onPublish"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -31,13 +37,13 @@ import UserCard from 'components/User/UserCard.vue'
|
||||
import MessageEditor from 'components/Message/MessageEditor.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
import ChatMessage from 'components/Message/ChatMessage.vue'
|
||||
import {useMessageStore} from 'src/nostr/store/MessageStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {bech32ToHex} from 'src/utils/utils'
|
||||
import { useMessageStore } from 'src/nostr/store/MessageStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { bech32ToHex } from 'src/utils/utils'
|
||||
|
||||
export default {
|
||||
name: 'Conversation',
|
||||
components: {ChatMessage, UserName, MessageEditor, PageHeader, UserCard},
|
||||
components: { ChatMessage, UserName, MessageEditor, PageHeader, UserCard },
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
@ -50,10 +56,12 @@ export default {
|
||||
},
|
||||
conversation() {
|
||||
if (!this.app.isSignedIn) return
|
||||
return this.messages.getConversation(this.app.myPubkey, this.counterparty)
|
||||
return this.messages.getConversation(
|
||||
this.app.myPubkey,
|
||||
this.counterparty
|
||||
)
|
||||
},
|
||||
placeholder() {
|
||||
// TODO i18n
|
||||
return this.app.myPubkey === this.counterparty
|
||||
? 'Jot something down'
|
||||
: 'Message'
|
||||
@ -81,7 +89,7 @@ export default {
|
||||
},
|
||||
unmounted() {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -107,7 +115,11 @@ export default {
|
||||
}
|
||||
|
||||
.conversation-reply {
|
||||
background: linear-gradient(to bottom, rgba($color: $color-bg, $alpha: 0), rgba($color: $color-bg, $alpha: 1) 6%);
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba($color: $color-bg, $alpha: 0),
|
||||
rgba($color: $color-bg, $alpha: 1) 6%
|
||||
);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: 600;
|
||||
@ -140,5 +152,4 @@ export default {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -2,24 +2,39 @@
|
||||
<PageHeader back-button>
|
||||
<template #addon>
|
||||
<q-btn icon="more_vert" size="md" round flat>
|
||||
<q-menu anchor="bottom right" self="top right" :offset="[0, 6]" class="options-popup">
|
||||
<a @click="markAllAsRead" v-close-popup>Mark all as read</a>
|
||||
<q-menu
|
||||
anchor="bottom right"
|
||||
self="top right"
|
||||
:offset="[0, 6]"
|
||||
class="options-popup"
|
||||
>
|
||||
<a @click="markAllAsRead" v-close-popup>{{
|
||||
$t("Mark all as read")
|
||||
}}</a>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="messages">
|
||||
<ConversationItem v-for="conversation in conversations" :key="conversation.pubkey" :conversation="conversation" />
|
||||
<p v-if="!conversations?.length">To send a message, click on the <BaseIcon icon="messages" /> icon in the recipient's profile.</p>
|
||||
<ConversationItem
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.pubkey"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<p v-if="!conversations?.length">
|
||||
{{ $t("To send a message, click on the") }}
|
||||
<BaseIcon icon="messages" />
|
||||
{{ $t("icon in the recipient's profile.") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PageHeader from 'components/PageHeader.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useMessageStore} from 'src/nostr/store/MessageStore'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { useMessageStore } from 'src/nostr/store/MessageStore'
|
||||
import ConversationItem from 'components/Message/ConversationItem.vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
|
||||
@ -47,7 +62,7 @@ export default {
|
||||
markAllAsRead() {
|
||||
this.messages.markAllAsRead(this.app.myPubkey)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -76,10 +91,10 @@ p {
|
||||
.options-popup {
|
||||
background-color: $color-bg;
|
||||
box-shadow: $shadow-white;
|
||||
border-radius: .5rem;
|
||||
border-radius: 0.5rem;
|
||||
a {
|
||||
display: block;
|
||||
padding: .5rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: 120ms ease;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
|
||||
|
@ -12,8 +12,8 @@
|
||||
indicator-color="primary"
|
||||
:breakpoint="0"
|
||||
>
|
||||
<q-tab name="following" label="Following" />
|
||||
<q-tab name="followers" label="Followers" />
|
||||
<q-tab name="following" :label="$t('Following')" />
|
||||
<q-tab name="followers" :label="$t('Followers')" />
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
@ -41,12 +41,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import PageHeader from 'components/PageHeader.vue'
|
||||
import UserCard from 'components/User/UserCard.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { bech32ToHex, hexToBech32 } from 'src/utils/utils'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
|
||||
export default defineComponent({
|
||||
@ -91,11 +91,11 @@ export default defineComponent({
|
||||
activeTab() {
|
||||
this.$router.replace({
|
||||
params: {
|
||||
tab: this.activeTab
|
||||
}
|
||||
tab: this.activeTab,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -133,7 +133,8 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
.profile-header-content .username {
|
||||
.name, .pubkey:first-child {
|
||||
.name,
|
||||
.pubkey:first-child {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
@ -12,20 +12,23 @@
|
||||
<p class="about">{{ profile?.about }}</p>
|
||||
<p class="followers">
|
||||
<a @click="goToFollowers('following')">
|
||||
<strong>{{ contacts?.length || 0 }}</strong> Following
|
||||
<strong>{{ contacts?.length || 0 }}</strong> {{ $t("Following") }}
|
||||
</a>
|
||||
<a @click="goToFollowers('followers')">
|
||||
<strong>{{ followers?.length ? `${followers?.length}+` : 0 }}</strong> Followers
|
||||
<strong>{{
|
||||
followers?.length ? `${followers?.length}+` : 0
|
||||
}}</strong>
|
||||
{{ $t("Followers") }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="actions">
|
||||
<a @click="goToConversation">
|
||||
<BaseIcon icon="messages" />
|
||||
<q-tooltip>Send private message</q-tooltip>
|
||||
<q-tooltip>{{ $t("Send private message") }}</q-tooltip>
|
||||
</a>
|
||||
<a :href="lightningLink" :class="{disabled: !lightningLink}">
|
||||
<a :href="lightningLink" :class="{ disabled: !lightningLink }">
|
||||
<q-icon name="bolt" size="sm" />
|
||||
<q-tooltip>Tip with Bitcoin Lightning</q-tooltip>
|
||||
<q-tooltip>{{ $t("Tip with Bitcoin Lightning") }}</q-tooltip>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@ -39,10 +42,10 @@
|
||||
indicator-color="primary"
|
||||
:breakpoint="0"
|
||||
>
|
||||
<q-tab name="posts" label="Posts" />
|
||||
<q-tab name="replies" label="Replies" />
|
||||
<q-tab name="reactions" label="Reactions" />
|
||||
<q-tab name="relays" label="Relays" />
|
||||
<q-tab name="posts" :label="$t('Posts')" />
|
||||
<q-tab name="replies" :label="$t('Replies')" />
|
||||
<q-tab name="reactions" :label="$t('Reactions')" />
|
||||
<q-tab name="relays" :label="$t('Relays')" />
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
@ -61,23 +64,21 @@
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="replies" class="no-padding">
|
||||
<template v-for="(thread, i) in replies">
|
||||
<Thread
|
||||
v-if="defer(i)"
|
||||
:key="thread[1].id"
|
||||
:thread="thread"
|
||||
/>
|
||||
<Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
|
||||
</template>
|
||||
<AsyncLoadLink :load-fn="loadMorePosts" :has-items="!!replies?.length" />
|
||||
<AsyncLoadLink
|
||||
:load-fn="loadMorePosts"
|
||||
:has-items="!!replies?.length"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="reactions" class="no-padding">
|
||||
<template v-for="(thread, i) in reactions">
|
||||
<Thread
|
||||
v-if="defer(i)"
|
||||
:key="thread[1].id"
|
||||
:thread="thread"
|
||||
/>
|
||||
<Thread v-if="defer(i)" :key="thread[1].id" :thread="thread" />
|
||||
</template>
|
||||
<AsyncLoadLink :load-fn="loadMoreReactions" :has-items="!!reactions?.length" />
|
||||
<AsyncLoadLink
|
||||
:load-fn="loadMoreReactions"
|
||||
:has-items="!!reactions?.length"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="relays" class="no-padding">
|
||||
<ListPlaceholder :count="0" />
|
||||
@ -87,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import PageHeader from 'components/PageHeader.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
@ -97,11 +98,11 @@ import ListPlaceholder from 'components/ListPlaceholder.vue'
|
||||
import FollowButton from 'components/User/FollowButton.vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import AsyncLoadLink from 'components/AsyncLoadLink.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {bech32ToHex, hexToBech32} from 'src/utils/utils'
|
||||
import { useAppStore } from 'stores/App'
|
||||
import { useNostrStore } from 'src/nostr/NostrStore'
|
||||
import { bech32ToHex, hexToBech32 } from 'src/utils/utils'
|
||||
import Defer from 'src/utils/Defer'
|
||||
import {EventKind} from 'src/nostr/model/Event'
|
||||
import { EventKind } from 'src/nostr/model/Event'
|
||||
import DateUtils from 'src/utils/DateUtils'
|
||||
|
||||
export default defineComponent({
|
||||
@ -143,16 +144,18 @@ export default defineComponent({
|
||||
return this.nostr.getPostsByAuthor(this.pubkey)
|
||||
},
|
||||
posts() {
|
||||
return this.notes?.filter(note => !note.hasAncestor())
|
||||
return this.notes?.filter((note) => !note.hasAncestor())
|
||||
},
|
||||
replies() {
|
||||
return this.notes?.filter(note => note.hasAncestor())
|
||||
.map(note => [this.nostr.getNote(note.ancestor()), note])
|
||||
return this.notes
|
||||
?.filter((note) => note.hasAncestor())
|
||||
.map((note) => [this.nostr.getNote(note.ancestor()), note])
|
||||
.slice(0, 50)
|
||||
},
|
||||
reactions() {
|
||||
return this.nostr.getReactionsByAuthor(this.pubkey)
|
||||
.map(note => [this.nostr.getNote(note.ancestor()), note])
|
||||
return this.nostr
|
||||
.getReactionsByAuthor(this.pubkey)
|
||||
.map((note) => [this.nostr.getNote(note.ancestor()), note])
|
||||
.slice(0, 50)
|
||||
},
|
||||
relays() {
|
||||
@ -174,7 +177,8 @@ export default defineComponent({
|
||||
},
|
||||
methods: {
|
||||
loadMorePosts() {
|
||||
const oldest = this.notes?.[this.notes.length - 1]?.createdAt || DateUtils.now()
|
||||
const oldest =
|
||||
this.notes?.[this.notes.length - 1]?.createdAt || DateUtils.now()
|
||||
return this.nostr.fetch({
|
||||
kinds: [EventKind.NOTE],
|
||||
authors: [this.pubkey],
|
||||
@ -183,7 +187,9 @@ export default defineComponent({
|
||||
})
|
||||
},
|
||||
loadMoreReactions() {
|
||||
const oldest = this.reactions?.[this.reactions.length - 1]?.createdAt || DateUtils.now()
|
||||
const oldest =
|
||||
this.reactions?.[this.reactions.length - 1]?.createdAt ||
|
||||
DateUtils.now()
|
||||
return this.nostr.fetch({
|
||||
kinds: [EventKind.REACTION],
|
||||
authors: [this.pubkey],
|
||||
@ -197,7 +203,7 @@ export default defineComponent({
|
||||
params: {
|
||||
pubkey: hexToBech32(this.pubkey, 'npub'),
|
||||
tab,
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
goToConversation() {
|
||||
@ -205,7 +211,7 @@ export default defineComponent({
|
||||
name: 'conversation',
|
||||
params: {
|
||||
pubkey: hexToBech32(this.pubkey, 'npub'),
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
@ -213,16 +219,18 @@ export default defineComponent({
|
||||
activeTab() {
|
||||
this.$router.replace({
|
||||
params: {
|
||||
tab: this.activeTab
|
||||
}
|
||||
tab: this.activeTab,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.nostr.fetchPostsByAuthor(this.pubkey, 50)
|
||||
.then(() => this.loadingNotes = false)
|
||||
this.nostr.fetchReactionsByAuthor(this.pubkey, 50)
|
||||
.then(() => this.loadingReactions = false)
|
||||
this.nostr
|
||||
.fetchPostsByAuthor(this.pubkey, 50)
|
||||
.then(() => (this.loadingNotes = false))
|
||||
this.nostr
|
||||
.fetchReactionsByAuthor(this.pubkey, 50)
|
||||
.then(() => (this.loadingReactions = false))
|
||||
this.nostr.fetchFollowers(this.pubkey, 1000)
|
||||
|
||||
// FIXME
|
||||
@ -230,7 +238,7 @@ export default defineComponent({
|
||||
},
|
||||
unmounted() {
|
||||
if (this.stream) this.nostr.cancelStream(this.stream)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -262,7 +270,8 @@ export default defineComponent({
|
||||
a {
|
||||
cursor: pointer;
|
||||
color: $color-light-gray;
|
||||
&:hover, &:active {
|
||||
&:hover,
|
||||
&:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
strong {
|
||||
@ -277,7 +286,8 @@ export default defineComponent({
|
||||
display: flex;
|
||||
a {
|
||||
text-decoration: none;
|
||||
svg, i {
|
||||
svg,
|
||||
i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $color-light-gray;
|
||||
@ -285,20 +295,22 @@ export default defineComponent({
|
||||
transition: 120ms ease;
|
||||
}
|
||||
&.disabled {
|
||||
svg, i {
|
||||
svg,
|
||||
i {
|
||||
color: $color-dark-gray !important;
|
||||
fill: $color-dark-gray !important;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
svg, i {
|
||||
svg,
|
||||
i {
|
||||
fill: $color-fg;
|
||||
color: $color-fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
a + a {
|
||||
margin-left: .5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import moment from 'moment/moment'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { es, enUS } from 'date-fns/locale'
|
||||
import { $t } from '../boot/i18n'
|
||||
|
||||
// TODO i18n
|
||||
const MONTHS = [
|
||||
@ -13,9 +15,11 @@ const MONTHS = [
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
'December',
|
||||
]
|
||||
|
||||
const [lng = 'en'] = (navigator?.language || '').split('-')
|
||||
|
||||
export default class DateUtils {
|
||||
static now() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
@ -23,9 +27,9 @@ export default class DateUtils {
|
||||
|
||||
static formatDate(timestamp) {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const month = MONTHS[date.getMonth()] // TODO i18n
|
||||
const month = $t(MONTHS[date.getMonth()]) // TODO i18n
|
||||
|
||||
const sameYear = date.getFullYear() === (new Date().getFullYear())
|
||||
const sameYear = date.getFullYear() === new Date().getFullYear()
|
||||
const year = !sameYear ? ' ' + date.getFullYear() : ''
|
||||
|
||||
return `${date.getDate()} ${month}${year}`
|
||||
@ -39,7 +43,9 @@ export default class DateUtils {
|
||||
}
|
||||
|
||||
static formatDateTime(timestamp) {
|
||||
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(timestamp)}`
|
||||
return `${DateUtils.formatDate(timestamp)}, ${DateUtils.formatTime(
|
||||
timestamp
|
||||
)}`
|
||||
}
|
||||
|
||||
static formatFromNow(timestamp, format = 'long') {
|
||||
@ -49,18 +55,22 @@ export default class DateUtils {
|
||||
}
|
||||
|
||||
static formatFromNowLong(timestamp) {
|
||||
return moment(timestamp * 1000).fromNow()
|
||||
return formatDistanceToNow(timestamp * 1000, {
|
||||
locale: lng === 'es' ? es : enUS,
|
||||
})
|
||||
}
|
||||
|
||||
static formatFromNowShort(timestamp) {
|
||||
const diff = Math.max(DateUtils.now() - timestamp, 0)
|
||||
const formatDiff = (unit, factor, offset) => Math.max(Math.floor((diff + (unit * offset)) / (unit * factor)), 1)
|
||||
const formatDiff = (unit, factor, offset) =>
|
||||
Math.max(Math.floor((diff + unit * offset) / (unit * factor)), 1)
|
||||
|
||||
if (diff < 45) return `${formatDiff(1, 1, 0)}s`
|
||||
if (diff < 60 * 45) return `${formatDiff(1, 60, 15)}m`
|
||||
if (diff < 60 * 60 * 22) return `${formatDiff(60, 60, 15)}h`
|
||||
if (diff < 60 * 60 * 24 * 26) return `${formatDiff(60 * 60, 24, 2)}d`
|
||||
if (diff < 60 * 60 * 24 * 30 * 320) return `${formatDiff(60 * 60 * 24, 30, 4)}mo`
|
||||
if (diff < 60 * 60 * 24 * 30 * 320)
|
||||
return `${formatDiff(60 * 60 * 24, 30, 4)}mo`
|
||||
return `${formatDiff(60 * 60 * 24, 30 * 365, 45)}y`
|
||||
}
|
||||
}
|
||||
|
10
yarn.lock
10
yarn.lock
@ -2618,6 +2618,11 @@ csstype@^2.6.8:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.21.tgz#2efb85b7cc55c80017c66a5ad7cbd931fda3a90e"
|
||||
integrity sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==
|
||||
|
||||
date-fns@^2.29.3:
|
||||
version "2.29.3"
|
||||
resolved "https://registry.npmmirror.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"
|
||||
integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
@ -4316,11 +4321,6 @@ minimist@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
|
||||
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
|
||||
|
||||
moment@^2.29.4:
|
||||
version "2.29.4"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
mrmime@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27"
|
||||
|
Loading…
Reference in New Issue
Block a user