Merge branch 'main' into feature/remove-link-if-matches-preview-URL

This commit is contained in:
Ayo 2023-02-05 19:25:11 +01:00
commit 354914fc8d
136 changed files with 2832 additions and 1923 deletions

View file

@ -1,5 +1,6 @@
NUXT_PUBLIC_TRANSLATE_API=
NUXT_PUBLIC_DEFAULT_SERVER=
SINGLE_INSTANCE_SERVER=
NUXT_PUBLIC_PRIVACY_POLICY_URL=
# Production only

View file

@ -44,6 +44,10 @@ These are known deployments using Elk as an alternative Web client for Mastodon
- [elk.h4.io](https://elk.h4.io) - Use Elk for the `h4.io` Server
- [elk.universeodon.com](https://elk.universeodon.com) - Use Elk for the Universeodon Server
- [elk.vmst.io](https://elk.vmst.io) - Use Elk for the `vmst.io` Server
- [elk.hostux.social](https://elk.hostux.social) - Use Elk for the `hostux.social` Server
- [elk.freelancers.online](https://elk.freelancers.online) - Use Elk for the `freelancers.online` Server
- [elk.cupoftea.social](https://elk.cupoftea.social) - Use Elk for the `cupoftea.social` Server
- [elk.aus.social](https://elk.aus.social) - Use Elk for the `aus.social` Server
> **Note**: Community deployments are **NOT** maintained by the Elk team. It may not be synced with Elk's source code. Please do your own research about the host servers before using them.

View file

@ -12,7 +12,7 @@ defineProps<{
>
<slot name="prepend" />
<CommonTooltip :content="$t('account.bot')" :disabled="showLabel">
<div i-ri:robot-line />
<div i-mdi:robot-outline />
</CommonTooltip>
<div v-if="showLabel">
{{ $t('account.bot') }}

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
defineProps<{
const { account, hideEmojis = false } = defineProps<{
account: mastodon.v1.Account
hideEmojis?: boolean
}>()
</script>
@ -10,6 +11,7 @@ defineProps<{
<ContentRich
:content="getDisplayName(account, { rich: true })"
:emojis="account.emojis"
:hide-emojis="hideEmojis"
:markdown="false"
/>
</template>

View file

@ -117,7 +117,7 @@ const buttonStyle = $computed(() => {
<span hidden group-hover="inline">{{ $t('account.follow_back') }}</span>
</template>
<template v-else>
<span>{{ $t('account.follow') }}</span>
<span>{{ account.locked ? $t('account.request_follow') : $t('account.follow') }}</span>
</template>
</button>
</template>

View file

@ -91,19 +91,22 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
</component>
<div p4 mt--18 flex flex-col gap-4>
<div relative>
<div flex="~ col gap-2 1">
<button :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
<div flex justify-between>
<button shrink-0 :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
<AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity />
</button>
<div flex="~ col gap1">
<div flex justify-between>
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
<AccountBotIndicator v-if="account.bot" show-label />
</div>
<AccountHandle :account="account" />
</div>
</div>
<div absolute top-18 inset-ie-0 flex gap-2 items-center>
<div inset-ie-0 flex="~ wrap row-reverse" gap-2 items-center pt18 justify-start>
<!-- Edit profile -->
<NuxtLink
v-if="isSelf"
to="/settings/profile/appearance"
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
hover="border-primary text-primary bg-active"
>
{{ $t('settings.profile.appearance.title') }}
</NuxtLink>
<AccountFollowButton :account="account" :command="command" />
<span inset-ie-0 flex gap-2 items-center>
<AccountMoreButton :account="account" :command="command" />
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
<button
@ -131,16 +134,15 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
</template>
</VDropdown>
</CommonTooltip>
<AccountFollowButton :account="account" :command="command" />
<!-- Edit profile -->
<NuxtLink
v-if="isSelf"
to="/settings/profile/appearance"
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
hover="border-primary text-primary bg-active"
>
{{ $t('settings.profile.appearance.title') }}
</NuxtLink>
</span>
</div>
</div>
<div flex="~ col gap1" pt2>
<div flex justify-between>
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
<AccountBotIndicator v-if="account.bot" show-label />
</div>
<AccountHandle :account="account" />
</div>
</div>
<div v-if="account.note" max-h-100 overflow-y-auto>
@ -154,12 +156,12 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
<ContentRich :content="field.value" :emojis="account.emojis" />
</div>
</div>
<div v-if="iconFields.length" flex="~ wrap gap-4">
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" items-center>
<div v-if="iconFields.length" flex="~ wrap gap-2">
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" px1 items-center :class="`${field.verifiedAt ? 'border-1 rounded-full border-dark' : ''}`">
<CommonTooltip :content="getFieldIconTitle(field.name)">
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
</CommonTooltip>
<ContentRich text-sm filter-saturate-0 :content="field.value" :emojis="account.emojis" />
<ContentRich text-sm :content="field.value" :emojis="account.emojis" />
</div>
</div>
<AccountPostsFollowers :account="account" />

View file

@ -6,6 +6,8 @@ const { link = true, avatar = true } = defineProps<{
link?: boolean
avatar?: boolean
}>()
const userSettings = useUserSettings()
</script>
<template>
@ -16,7 +18,7 @@ const { link = true, avatar = true } = defineProps<{
min-w-0 flex gap-2 items-center
>
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all />
<AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" line-clamp-1 ws-pre-wrap break-all />
</NuxtLink>
</AccountHoverWrapper>
</template>

View file

@ -64,6 +64,9 @@ const result = $computed<QueryResult>(() => commandMode
: searchResult,
)
const isMac = useIsMac()
const modifierKeyName = $computed(() => isMac.value ? '⌘' : 'Ctrl')
let active = $ref(0)
watch($$(result), (n, o) => {
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
@ -233,8 +236,8 @@ const onKeyDown = (e: KeyboardEvent) => {
<!-- Footer -->
<div class="flex items-center px-3 py-1 text-xs">
<div i-ri:lightbulb-flash-line /> Tip: Use
<CommandKey name="Ctrl+K" /> to search,
<CommandKey name="Ctrl+/" /> to activate command mode.
<CommandKey :name="`${modifierKeyName}+K`" /> to search,
<CommandKey :name="`${modifierKeyName}+/`" /> to activate command mode.
</div>
</div>
</template>

View file

@ -1,22 +1,18 @@
<script setup lang="ts">
defineProps<{
describedBy: string
}>()
defineOptions({
inheritAttrs: false,
})
defineProps<{ describedBy: string }>()
</script>
<template>
<div
role="alert"
aria-live="polite"
:aria-describedby="describedBy"
flex="~ col"
gap-1 text-sm
pt-1 ps-2 pe-1 pb-2
text-red-600 dark:text-red-400
border="~ base rounded red-600 dark:red-400"
v-bind="$attrs"
>
<slot />
</div>

View file

@ -43,8 +43,31 @@ defineSlots<{
}>()
const { t } = useI18n()
const nuxtApp = useNuxtApp()
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, $$(stream), eventType, preprocess)
nuxtApp.hook('elk-logo:click', () => {
update()
nuxtApp.$scrollToTop()
})
function createEntry(item: any) {
items.value = [...items.value, preprocess?.([item]) ?? item]
}
function updateEntry(item: any) {
const id = item[keyProp]
const index = items.value.findIndex(i => (i as any)[keyProp] === id)
if (index > -1)
items.value = [...items.value.slice(0, index), preprocess?.([item]) ?? item, ...items.value.slice(index + 1)]
}
function removeEntry(entryId: any) {
items.value = items.value.filter(i => (i as any)[keyProp] !== entryId)
}
defineExpose({ createEntry, removeEntry, updateEntry })
</script>
<template>

View file

@ -46,6 +46,7 @@ useCommand({
v-bind="$attrs"
:is="is"
ref="el"
w-full
flex gap-3 items-center cursor-pointer px4 py3
select-none
hover-bg-active

View file

@ -7,10 +7,12 @@ defineOptions({
const {
content,
emojis,
hideEmojis = false,
markdown = true,
} = defineProps<{
content: string
emojis?: mastodon.v1.CustomEmoji[]
hideEmojis?: boolean
markdown?: boolean
}>()
@ -21,6 +23,7 @@ export default () => h(
{ class: 'content-rich', dir: 'auto' },
contentToVNode(content, {
emojis: emojisObject.value,
hideEmojis,
markdown,
}),
)

View file

@ -6,7 +6,8 @@ const { paginator } = defineProps<{
}>()
function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] {
return items.filter(items => !items.lastStatus?.filtered?.find(
const isAuthored = (conversation: mastodon.v1.Conversation) => conversation.lastStatus ? conversation.lastStatus.account.id === currentUser.value?.account.id : false
return items.filter(item => isAuthored(item) || !item.lastStatus?.filtered?.find(
filter => filter.filter.filterAction === 'hide' && filter.filter.context.includes('thread'),
))
}

View file

@ -30,7 +30,7 @@ const emit = defineEmits<{
</p>
{{ $t('help.desc_para3') }}
<p flex="~ gap-2 wrap" mxa>
<template v-for="team of teams" :key="team.github">
<template v-for="team of elkTeamMembers" :key="team.github">
<NuxtLink :href="`https://github.com/sponsors/${team.github}`" target="_blank" external rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
<img :src="`/avatars/${team.github}-100x100.png`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
</NuxtLink>
@ -38,7 +38,7 @@ const emit = defineEmits<{
</p>
<p italic flex justify-center w-full>
<NuxtLink href="https://github.com/sponsors/elk-zone" target="_blank">
<span text-xl font-script hover:text-primary transition duration-300>The Elk Team</span>
<span text-xl font-script hover:text-primary transition duration-300>{{ $t('help.footer_team') }}</span>
</NuxtLink>
</p>

View file

@ -36,8 +36,19 @@ async function edit() {
:to="getAccountRoute(account)"
/>
<div>
<CommonTooltip :content="isRemoved ? $t('list.add_account') : $t('list.remove_account')" :hover="isRemoved ? 'text-green' : 'text-red'">
<button :class="isRemoved ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'" text-xl @click="edit" />
<CommonTooltip
:content="isRemoved ? $t('list.add_account') : $t('list.remove_account')"
:hover="isRemoved ? 'text-green' : 'text-red'"
no-auto-focus
>
<button
text-sm p2 border-1 transition-colors
border-dark
btn-action-icon
@click="edit"
>
<span :class="isRemoved ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'" />
</button>
</CommonTooltip>
</div>
</div>

View file

@ -0,0 +1,233 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const emit = defineEmits<{
(e: 'listUpdated', list: mastodon.v1.List): void
(e: 'listRemoved', id: string): void
}>()
const { list } = $defineProps<{
list: mastodon.v1.List
}>()
const { modelValue } = defineModel<{
modelValue: string
}>()
modelValue.value = list.title
const { t } = useI18n()
const client = useMastoClient()
let isEditing = $ref<boolean>(false)
let busy = $ref<boolean>(false)
let deleteBusy = $ref<boolean>(false)
let actionError = $ref<string | undefined>(undefined)
const enableSaveButton = computed(() => list.title !== modelValue.value)
const edit = ref()
const deleteBtn = ref()
const input = ref()
const prepareEdit = () => {
isEditing = true
actionError = undefined
nextTick(() => {
input.value?.focus()
})
}
const cancelEdit = () => {
isEditing = false
actionError = undefined
modelValue.value = list.title
nextTick(() => {
edit.value?.focus()
})
}
async function finishEditing() {
if (busy || !isEditing || !enableSaveButton.value)
return
busy = true
actionError = undefined
await nextTick()
try {
const updateList = await client.v1.lists.update(list.id, {
title: modelValue.value,
})
cancelEdit()
emit('listUpdated', updateList)
}
catch (err) {
console.error(err)
actionError = (err as Error).message
nextTick(() => {
input.value?.focus()
})
}
finally {
busy = false
}
}
async function removeList() {
if (deleteBusy)
return
deleteBusy = true
actionError = undefined
await nextTick()
const confirmDelete = await openConfirmDialog({
title: t('confirm.delete_list.title', [list.title]),
confirm: t('confirm.delete_list.confirm'),
cancel: t('confirm.delete_list.cancel'),
})
if (confirmDelete === 'confirm') {
await nextTick()
try {
await client.v1.lists.remove(list.id)
emit('listRemoved', list.id)
}
catch (err) {
console.error(err)
actionError = (err as Error).message
nextTick(() => {
deleteBtn.value?.focus()
})
}
finally {
deleteBusy = false
}
}
else {
deleteBusy = false
}
}
function clearError() {
actionError = undefined
nextTick(() => {
if (isEditing)
input.value?.focus()
else
deleteBtn.value?.focus()
})
}
onDeactivated(cancelEdit)
</script>
<template>
<form
hover:bg-active flex justify-between items-center gap-x-2
:aria-describedby="actionError ? `action-list-error-${list.id}` : undefined"
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
@submit.prevent="finishEditing"
>
<div
v-if="isEditing"
bg-base border="~ base" h10 m2 ps-1 pe-4 rounded-3 w-full flex="~ row"
items-center relative focus-within:box-shadow-outline gap-3
>
<CommonTooltip v-if="isEditing" :content="$t('list.cancel_edit')" no-auto-focus>
<button
type="button"
rounded-full text-sm p2 transition-colors
hover:text-primary
@click="cancelEdit()"
>
<span block text-current i-ri:close-fill />
</button>
</CommonTooltip>
<input
ref="input"
v-model="modelValue"
rounded-3
w-full
bg-transparent
outline="focus:none"
pe-4
pb="1px"
flex-1
placeholder-text-secondary
@keydown.esc="cancelEdit()"
>
</div>
<NuxtLink v-else :to="`list/${list.id}`" block grow p4>
{{ list.title }}
</NuxtLink>
<div mr4 flex gap2>
<CommonTooltip v-if="isEditing" :content="$t('list.save')" no-auto-focus>
<button
type="submit"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
:disabled="deleteBusy || !enableSaveButton || busy"
>
<template v-if="isEditing">
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block text-current i-ri:save-2-fill class="rtl-flip" />
</template>
</button>
</CommonTooltip>
<CommonTooltip v-else :content="$t('list.edit')" no-auto-focus>
<button
ref="edit"
type="button"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
@click.prevent="prepareEdit"
>
<span block text-current i-ri:edit-2-line class="rtl-flip" />
</button>
</CommonTooltip>
<CommonTooltip :content="$t('list.delete')" no-auto-focus>
<button
ref="delete"
type="button"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
:disabled="isEditing"
@click.prevent="removeList"
>
<span v-if="deleteBusy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block text-current i-ri:delete-bin-2-line class="rtl-flip" />
</button>
</CommonTooltip>
</div>
</form>
<CommonErrorMessage
v-if="actionError"
:id="`action-list-error-${list.id}`"
:described-by="`action-list-failed-${list.id}`"
class="rounded-bs-is-0 rounded-bs-ie-0 border-t-dashed m-b-2"
>
<header :id="`action-list-failed-${list.id}`" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t(`list.${isEditing ? 'edit_error' : 'delete_error'}`) }}</p>
</div>
<CommonTooltip placement="bottom" :content="$t('list.clear_error')" no-auto-focus>
<button
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('list.clear_error')"
@click="clearError"
>
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
</button>
</CommonTooltip>
</header>
<ol ps-2 sm:ps-1>
<li flex="~ col sm:row" gap-y-1 sm:gap-x-2>
<strong sr-only>{{ $t('list.error_prefix') }}</strong>
<span>{{ actionError }}</span>
</li>
</ol>
</CommonErrorMessage>
</template>

View file

@ -39,9 +39,13 @@ async function edit(listId: string) {
:hover="indexOfUserInList(item.id) === -1 ? 'text-green' : 'text-red'"
>
<button
:class="indexOfUserInList(item.id) === -1 ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'"
text-xl @click="() => edit(item.id)"
/>
text-sm p2 border-1 transition-colors
border-dark
btn-action-icon
@click="() => edit(item.id)"
>
<span :class="indexOfUserInList(item.id) === -1 ? 'i-ri:user-add-line' : 'i-ri:user-unfollow-line'" />
</button>
</CommonTooltip>
</div>
</template>

View file

@ -4,6 +4,8 @@ defineProps<{
backOnSmallScreen?: boolean
/** Show the back button on both small and big screens */
back?: boolean
/** Do not applying overflow hidden to let use floatable components in title */
noOverflowHidden?: boolean
}>()
const route = useRoute()
@ -18,8 +20,8 @@ const wideLayout = computed(() => route.meta.wideLayout ?? false)
border="b base" bg="[rgba(var(--rgb-bg-base),0.7)]"
class="native:lg:w-[calc(100vw-5rem)] native:xl:w-[calc(135%+(100vw-1200px)/2)]"
>
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" data-tauri-drag-region class="native:xl:flex">
<div flex gap-3 items-center overflow-hidden py2 class="native-mac:pl-14 native-mac:sm:pl-0">
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" class="native:xl:flex">
<div flex gap-3 items-center :overflow-hidden="!noOverflowHidden ? '' : false" py2 w-full>
<NuxtLink
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 xl:hidden
:aria-label="$t('nav.back')"
@ -27,10 +29,10 @@ const wideLayout = computed(() => route.meta.wideLayout ?? false)
>
<div i-ri:arrow-left-line class="rtl-flip" />
</NuxtLink>
<div truncate>
<div :truncate="!noOverflowHidden ? '' : false" flex w-full data-tauri-drag-region class="native-mac:justify-center native-mac:text-center native-mac:sm:justify-start">
<slot name="title" />
</div>
<div h-7 w-1px />
<div sm:hidden h-7 w-1px />
</div>
<div flex items-center flex-shrink-0 gap-x-2>
<slot name="actions" />

View file

@ -5,6 +5,7 @@ import {
isCommandPanelOpen,
isConfirmDialogOpen,
isEditHistoryDialogOpen,
isErrorDialogOpen,
isFavouritedBoostedByDialogOpen,
isMediaPreviewOpen,
isPreviewHelpOpen,
@ -87,6 +88,9 @@ const handleFavouritedBoostedByClose = () => {
<ModalDialog v-model="isConfirmDialogOpen" py-4 px-8 max-w-125>
<ModalConfirm v-if="confirmDialogLabel" v-bind="confirmDialogLabel" @choice="handleConfirmChoice" />
</ModalDialog>
<ModalDialog v-model="isErrorDialogOpen" py-4 px-8 max-w-125>
<ModalError v-if="errorDialogData" v-bind="errorDialogData" />
</ModalDialog>
<ModalDialog
v-model="isFavouritedBoostedByDialogOpen"
max-w-180

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { ErrorDialogData } from '~/types'
defineProps<ErrorDialogData>()
</script>
<template>
<div flex="~ col" gap-6>
<div font-bold text-lg text-center>
{{ title }}
</div>
<div
flex="~ col"
gap-1 text-sm
pt-1 ps-2 pe-1 pb-2
text-red-600 dark:text-red-400
border="~ base rounded red-600 dark:red-400"
>
<ol ps-2 sm:ps-1>
<li v-for="(message, i) in messages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
{{ message }}
</li>
</ol>
</div>
<div flex justify-end gap-2>
<button btn-text @click="closeErrorDialog()">
{{ close }}
</button>
</div>
</div>
</template>

View file

@ -1,5 +1,7 @@
<script setup lang="ts">
import { SwipeDirection } from '@vueuse/core'
import { useGesture } from '@vueuse/gesture'
import type { PermissiveMotionProperties } from '@vueuse/motion'
import { useReducedMotion } from '@vueuse/motion'
import type { mastodon } from 'masto'
@ -23,18 +25,36 @@ const reduceMotion = useReducedMotion()
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
const { motionProperties } = useMotionProperties(target, {
cursor: 'grab',
scale: 1,
x: 0,
y: 0,
})
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
function resetZoom() {
set({ scale: 1 })
}
watch(modelValue, resetZoom)
const { width, height } = useElementSize(target)
const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
threshold: 5,
passive: false,
onSwipeEnd(e, direction) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold)
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold) {
modelValue.value = Math.max(0, modelValue.value - 1)
resetZoom()
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold)
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold) {
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
resetZoom()
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > threshold)
@ -42,6 +62,21 @@ const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
},
})
useGesture({
onPinch({ offset: [distance, angle] }) {
set({ scale: 1 + distance / 200 })
},
onMove({ movement: [x, y], dragging, pinching }) {
if (dragging && !pinching)
set({ x, y })
},
}, {
domTarget: target,
eventOptions: {
passive: true,
},
})
const distanceX = computed(() => {
if (width.value === 0)
return 0

View file

@ -14,7 +14,7 @@ const moreMenuVisible = ref(false)
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:home-5-line />
</NuxtLink>
<NuxtLink to="/search" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<NuxtLink :to="isHydrated ? `/${currentServer}/explore` : '/explore'" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:search-line />
</NuxtLink>
<NuxtLink to="/notifications" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
@ -28,9 +28,6 @@ const moreMenuVisible = ref(false)
<NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:hashtag />
</NuxtLink>
<NuxtLink to="/search" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:search-line />
</NuxtLink>
<NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:group-2-line />
</NuxtLink>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
const buildInfo = useRuntimeConfig().public.buildInfo
const buildInfo = useAppConfig().buildInfo
const timeAgoOptions = useTimeAgoOptions()
const userSettings = useUserSettings()

View file

@ -6,12 +6,11 @@ const { notifications } = useNotifications()
</script>
<template>
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg>
<div shrink hidden sm:block mt-4 />
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg h-full>
<SearchWidget lg:ms-1 lg:me-5 hidden xl:block />
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" xl:hidden :command="command" />
<NavSideItem :text="$t('nav.search')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:search-line" hidden sm:block xl:hidden :command="command" />
<div shrink hidden sm:block mt-4 />
<div shrink hidden sm:block mt-2 />
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
<template #icon>
@ -29,10 +28,10 @@ const { notifications } = useNotifications()
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
<div shrink hidden sm:block mt-4 />
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" />
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" xs:hidden sm:hidden xl:block />
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
<NavSideItem :text="$t('nav.lists')" :to="`/${currentServer}/lists`" icon="i-ri:list-check" :command="command" />
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
<div shrink hidden sm:block mt-4 />
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />

View file

@ -2,6 +2,13 @@
const { env } = useBuildInfo()
const router = useRouter()
const back = ref<any>('')
const nuxtApp = useNuxtApp()
const onClickLogo = () => {
nuxtApp.hooks.callHook('elk-logo:click')
}
onMounted(() => {
back.value = router.options.history.state.back
})
@ -11,16 +18,15 @@ router.afterEach(() => {
</script>
<template>
<!-- Use external to force refresh page and jump to top of timeline -->
<div flex justify-between>
<div flex justify-between sticky top-0 bg-base z-1 py-4 native:py-7 data-tauri-drag-region>
<NuxtLink
flex items-end gap-4
flex items-end gap-3
py2 px-5
text-2xl
select-none
focus-visible:ring="2 current"
to="/"
external
to="/home"
@click.prevent="onClickLogo"
>
<NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
<div hidden xl:block text-secondary>

View file

@ -1,3 +1,7 @@
<script setup>
const { busy, oauth, singleInstanceServer } = useSignIn()
</script>
<template>
<VDropdown v-if="isHydrated && currentUser" sm:hidden>
<div style="-webkit-touch-callout: none;">
@ -15,7 +19,24 @@
<UserSwitcher ref="switcher" @click="hide()" />
</template>
</VDropdown>
<template v-else>
<button
v-if="singleInstanceServer"
flex="~ row"
gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
:disabled="busy"
@click="oauth()"
>
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
<i18n-t keypath="action.sign_in_to">
<strong>{{ currentServer }}</strong>
</i18n-t>
</button>
<button v-else btn-solid text-sm px-2 py-1 text-center xl:hidden @click="openSigninDialog()">
{{ $t('action.sign_in') }}
</button>
</template>
</template>

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import { PushSubscriptionError } from '~/composables/push-notifications/types'
defineProps<{ show?: boolean }>()
const {
@ -17,7 +15,7 @@ const {
} = usePushManager()
const { t } = useI18n()
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
const pwaEnabled = useAppConfig().pwaEnabled
let busy = $ref<boolean>(false)
let animateSave = $ref<boolean>(false)

View file

@ -36,5 +36,11 @@ const { modelValue } = defineModel<{
</CommonTooltip>
</head>
<p>{{ message }}</p>
<p py-2>
<NuxtLink font-bold text-primary href="https://github.com/elk-zone/elk" target="_blank" flex="~ row" items-center gap-x-2>
{{ $t('settings.notifications.push_notifications.subscription_error.repo_link') }}
<span inline-block aria-hidden="true" i-ri:external-link-line class="rtl-flip" />
</NuxtLink>
</p>
</div>
</template>

View file

@ -34,7 +34,7 @@ const toggleApply = () => {
text-white px2 py2 rounded-full cursor-pointer
@click="$emit('remove')"
>
<div i-ri:close-line text-3 :class="[isHydrated && isSmallScreen ? 'text-6' : 'text-3']" />
<div i-ri:close-line text-3 text-6 md:text-3 />
</div>
</div>
<div absolute right-2 bottom-2>

View file

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { Editor } from '@tiptap/core'
const { editor } = defineProps<{
editor: Editor
}>()
</script>
<template>
<CommonTooltip placement="top" :content="$t('tooltip.open_editor_tools')">
<VDropdown v-if="editor" placement="top">
<button
btn-action-icon
>
<div i-ri:font-size-2 />
</button>
<template #popper>
<div flex gap-1>
<CommonTooltip placement="top" :content="$t('tooltip.toggle_code_block')">
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_code_block')"
:class="editor.isActive('codeBlock') ? 'text-primary' : ''"
@click="editor?.chain().focus().toggleCodeBlock().run()"
>
<div i-ri:code-s-slash-line />
</button>
</CommonTooltip>
<CommonTooltip placement="top" :content="$t('tooltip.toggle_bold')">
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_bold')"
:class="editor.isActive('bold') ? 'text-primary' : ''"
@click="editor?.chain().focus().toggleBold().run()"
>
<div i-ri:bold />
</button>
</CommonTooltip>
<CommonTooltip placement="top" :content="$t('tooltip.toggle_italic')">
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_italic')"
:class="editor.isActive('italic') ? 'text-primary' : ''"
@click="editor?.chain().focus().toggleItalic().run()"
>
<div i-ri:italic />
</button>
</CommonTooltip>
</div>
</template>
</VDropdown>
</CommonTooltip>
</template>

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import ISO6391 from 'iso-639-1'
import Fuse from 'fuse.js'
let { modelValue } = $defineModel<{
@ -7,20 +6,11 @@ let { modelValue } = $defineModel<{
}>()
const { t } = useI18n()
const userSettings = useUserSettings()
const languageKeyword = $ref('')
const languageList: {
code: string
nativeName: string
name: string
}[] = ISO6391.getAllCodes().map(code => ({
code,
nativeName: ISO6391.getNativeName(code),
name: ISO6391.getName(code),
}))
const fuse = new Fuse(languageList, {
const fuse = new Fuse(languagesNameList, {
keys: ['code', 'nativeName', 'name'],
shouldSort: true,
})
@ -28,25 +18,52 @@ const fuse = new Fuse(languageList, {
const languages = $computed(() =>
languageKeyword.trim()
? fuse.search(languageKeyword).map(r => r.item)
: [...languageList].sort(({ code: a }, { code: b }) => {
: [...languagesNameList].filter(entry => !userSettings.value.disabledTranslationLanguages.includes(entry.code))
.sort(({ code: a }, { code: b }) => {
return a === modelValue ? -1 : b === modelValue ? 1 : a.localeCompare(b)
}),
)
const preferredLanguages = computed(() => {
const result = []
for (const langCode of userSettings.value.disabledTranslationLanguages) {
const completeLang = languagesNameList.find(listEntry => listEntry.code === langCode)
if (completeLang)
result.push(completeLang)
}
return result
},
)
function chooseLanguage(language: string) {
modelValue = language
}
</script>
<template>
<div>
<div relative of-x-hidden>
<div p2>
<input
v-model="languageKeyword"
:placeholder="t('language.search')"
p2 mb2 border-rounded w-full bg-transparent
p2 border-rounded w-full bg-transparent
outline-none border="~ base"
>
</div>
<div max-h-40vh overflow-auto>
<template v-if="!languageKeyword.trim()">
<CommonDropdownItem
v-for="{ code, nativeName, name } in preferredLanguages"
:key="code"
:text="nativeName"
:description="name"
:checked="code === modelValue"
@click="chooseLanguage(code)"
/>
<hr class="border-base ">
</template>
<CommonDropdownItem
v-for="{ code, nativeName, name } in languages"
:key="code"

View file

@ -6,7 +6,7 @@ import type { Draft } from '~/types'
const {
draftKey,
initial = getDefaultDraft() as never /* Bug of vue-core */,
initial = getDefaultDraft,
expanded = false,
placeholder,
dialogLabelledBy,
@ -35,7 +35,7 @@ const {
dropZoneRef,
} = $(useUploadMediaAttachment($$(draft)))
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages } = $(usePublish(
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = $(usePublish(
{
draftState,
...$$({ expanded, isUploading, initialDraft: initial }),
@ -62,6 +62,7 @@ const { editor } = useTiptap({
},
onPaste: handlePaste,
})
const characterCount = $computed(() => {
let length = stringLength(htmlToText(editor.value?.getHTML() || ''))
@ -73,9 +74,13 @@ const characterCount = $computed(() => {
}).join(' ').length + 1
}
length += stringLength(publishSpoilerText)
return length
})
const postLanguageDisplay = $computed(() => languagesNameList.find(i => i.code === (draft.params.language || preferredLanguage))?.nativeName)
async function handlePaste(evt: ClipboardEvent) {
const files = evt.clipboardData?.files
if (!files || files.length === 0)
@ -108,10 +113,16 @@ useWebShareTarget(async ({ data: { data, action } }: any) => {
editor.value?.commands.focus('end')
if (data.text !== undefined)
editor.value?.commands.insertContent(data.text)
for (const text of data.textParts) {
for (const line of text.split('\n')) {
editor.value?.commands.insertContent({
type: 'paragraph',
content: [{ type: 'text', text: line }],
})
}
}
if (data.files !== undefined)
if (data.files.length !== 0)
await uploadAttachments(data.files)
})
@ -147,13 +158,13 @@ defineExpose({
>
<ContentMentionGroup v-if="draft.mentions?.length && shouldExpanded" replying>
<button v-for="m, i of draft.mentions" :key="m" text-primary hover:color-red @click="draft.mentions?.splice(i, 1)">
{{ acctToShortHandle(m) }}
{{ accountToShortHandle(m) }}
</button>
</ContentMentionGroup>
<div v-if="draft.params.sensitive">
<input
v-model="draft.params.spoilerText"
v-model="publishSpoilerText"
type="text"
:placeholder="$t('placeholder.content_warning')"
p2 border-rounded w-full bg-transparent
@ -161,8 +172,8 @@ defineExpose({
>
</div>
<PublishErrMessage v-if="failedMessages.length > 0" described-by="publish-failed">
<head id="publish-failed" flex justify-between>
<CommonErrorMessage v-if="failedMessages.length > 0" described-by="publish-failed">
<header id="publish-failed" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t('state.publish_failed') }}</p>
@ -175,14 +186,14 @@ defineExpose({
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
</button>
</CommonTooltip>
</head>
</header>
<ol ps-2 sm:ps-1>
<li v-for="(error, i) in failedMessages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
<strong>{{ i + 1 }}.</strong>
<span>{{ error }}</span>
</li>
</ol>
</PublishErrMessage>
</CommonErrorMessage>
<div relative flex-1 flex flex-col>
<EditorContent
@ -198,11 +209,11 @@ defineExpose({
</div>
{{ $t('state.uploading') }}
</div>
<PublishErrMessage
<CommonErrorMessage
v-else-if="failedAttachments.length > 0"
:described-by="isExceedingAttachmentLimit ? 'upload-failed uploads-per-post' : 'upload-failed'"
>
<head id="upload-failed" flex justify-between>
<header id="upload-failed" flex justify-between>
<div flex items-center gap-x-2 font-bold>
<div aria-hidden="true" i-ri:error-warning-fill />
<p>{{ $t('state.upload_failed') }}</p>
@ -215,7 +226,7 @@ defineExpose({
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
</button>
</CommonTooltip>
</head>
</header>
<div v-if="isExceedingAttachmentLimit" id="uploads-per-post" ps-2 sm:ps-1 text-small>
{{ $t('state.attachments_exceed_server_limit') }}
</div>
@ -225,7 +236,7 @@ defineExpose({
<span>{{ error[0] }}</span>
</li>
</ol>
</PublishErrMessage>
</CommonErrorMessage>
<div v-if="draft.attachments.length" flex="~ col gap-2" overflow-auto>
<PublishAttachment
@ -259,18 +270,7 @@ defineExpose({
</button>
</CommonTooltip>
<template v-if="editor">
<CommonTooltip placement="top" :content="$t('tooltip.toggle_code_block')">
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_code_block')"
:class="editor.isActive('codeBlock') ? 'text-primary' : ''"
@click="editor?.chain().focus().toggleCodeBlock().run()"
>
<div i-ri:code-s-slash-line />
</button>
</CommonTooltip>
</template>
<PublishEditorTools v-if="editor" :editor="editor" />
<div flex-auto />
@ -278,6 +278,20 @@ defineExpose({
{{ characterCount ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ characterLimit }}</span>
</div>
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
<CommonDropdown placement="bottom" auto-boundary-max-size>
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
<div v-else i-ri:translate-2 />
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
<PublishLanguagePicker v-model="draft.params.language" min-w-80 />
</template>
</CommonDropdown>
</CommonTooltip>
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
@ -285,19 +299,6 @@ defineExpose({
</button>
</CommonTooltip>
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
<CommonDropdown placement="bottom" auto-boundary-max-size>
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-12 mr--1>
<div i-ri:translate-2 />
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
<PublishLanguagePicker v-model="draft.params.language" min-w-80 p3 />
</template>
</CommonDropdown>
</CommonTooltip>
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
<template #default="{ visibility }">
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">

View file

@ -5,9 +5,14 @@ const index = ref(0)
const { t } = useI18n()
const el = ref<HTMLElement>()
const input = ref<HTMLInputElement>()
const router = useRouter()
const { focused } = useFocusWithin(el)
defineExpose({
input,
})
const results = computed(() => {
if (query.value.length === 0)
return []
@ -68,6 +73,7 @@ const activate = () => {
bg-transparent
outline="focus:none"
pe-4
ml-1
:placeholder="isHydrated ? t('nav.search') : ''"
pb="1px"
placeholder-text-secondary
@ -77,7 +83,7 @@ const activate = () => {
>
</div>
<!-- Results -->
<div left-0 top-12 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
<div left-0 top-11 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
<div w-full bg-base border="~ base" rounded-3 max-h-100 overflow-auto py2>
<span v-if="query.trim().length === 0" block text-center text-sm text-secondary>
{{ t('search.search_desc') }}

View file

@ -9,7 +9,7 @@ defineProps<{
<template>
<button
exact-active-class="text-primary"
block w-full group focus:outline-none
block w-full group focus:outline-none text-start
>
<div
w-full flex w-fit px5 py3 md:gap2 gap4 items-center
@ -18,7 +18,8 @@ defineProps<{
>
<div flex-1 flex items-center md:gap2 gap4>
<div
flex items-center justify-center flex-shrink-0
v-if="icon" flex items-center justify-center
flex-shrink-0
:class="$slots.description ? 'w-12 h-12' : ''"
>
<slot name="icon">

View file

@ -0,0 +1,63 @@
<script lang="ts" setup>
import ISO6391 from 'iso-639-1'
const supportedTranslationLanguages = ISO6391.getLanguages([...supportedTranslationCodes])
const userSettings = useUserSettings()
const language = ref<string | null>(null)
const availableOptions = computed(() => {
return Object.values(supportedTranslationLanguages).filter((value) => {
return !userSettings.value.disabledTranslationLanguages.includes(value.code)
})
})
function addDisabledTranslation() {
if (language.value) {
const uniqueValues = new Set(userSettings.value.disabledTranslationLanguages)
uniqueValues.add(language.value)
userSettings.value.disabledTranslationLanguages = [...uniqueValues]
language.value = null
}
}
function removeDisabledTranslation(code: string) {
const uniqueValues = new Set(userSettings.value.disabledTranslationLanguages)
uniqueValues.delete(code)
userSettings.value.disabledTranslationLanguages = [...uniqueValues]
}
</script>
<template>
<div>
<CommonCheckbox v-model="userSettings.preferences.hideTranslation" :label="$t('settings.preferences.hide_translation')" />
<div v-if="!userSettings.preferences.hideTranslation" class="mt-1 ms-2">
<p class=" mb-2">
{{ $t('settings.language.translations.hide_specific') }}
</p>
<div class="ms-4">
<ul>
<li v-for="langCode in userSettings.disabledTranslationLanguages" :key="langCode" class="flex items-center">
<div>{{ ISO6391.getNativeName(langCode) }}</div>
<button class="btn-text" type="button" :title="$t('settings.language.translations.remove')" @click.prevent="removeDisabledTranslation(langCode)">
<span class="block i-ri:close-line" aria-hidden="true" />
</button>
</li>
</ul>
<div class="flex items-center mt-2">
<select v-model="language" class="select-settings">
<option disabled selected :value="null">
{{ $t('settings.language.translations.choose_language') }}
</option>
<option v-for="availableOption in availableOptions" :key="availableOption.code" :value="availableOption.code">
{{ availableOption.nativeName }}
</option>
</select>
<button class="btn-text" @click="addDisabledTranslation">
{{ $t('settings.language.translations.add') }}
</button>
</div>
</div>
</div>
</div>
</template>

View file

@ -5,6 +5,8 @@ const { account, link = true } = defineProps<{
account: mastodon.v1.Account
link?: boolean
}>()
const userSettings = useUserSettings()
</script>
<template>
@ -13,7 +15,7 @@ const { account, link = true } = defineProps<{
flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
text-link-rounded
>
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
<AccountDisplayName :account="account" :hide-emojis="getPreferences(userSettings, 'hideUsernameEmojis')" font-bold line-clamp-1 ws-pre-wrap break-all />
<AccountHandle :account="account" class="zen-none" />
</NuxtLink>
</template>

View file

@ -49,7 +49,7 @@ useCommand({
<component
:is="as"
v-bind="$attrs" ref="el"
w-fit flex gap-1 items-center transition-all
w-fit flex gap-1 items-center transition-all select-none
rounded group
:hover=" !disabled ? hover : undefined"
focus:outline-none

View file

@ -87,6 +87,8 @@ useIntersectionObserver(video, (entries) => {
}
})
}, { threshold: 0.75 })
const userSettings = useUserSettings()
</script>
<template>
@ -167,7 +169,7 @@ useIntersectionObserver(video, (entries) => {
/>
</button>
</template>
<div v-if="attachment.description" :class="isAudio ? '' : 'absolute left-2 bottom-2'">
<div v-if="attachment.description && !getPreferences(userSettings, 'hideAltIndicatorOnPosts')" :class="isAudio ? '' : 'absolute left-2 bottom-2'">
<VDropdown :distance="6" placement="bottom-start">
<button
font-bold text-sm
@ -176,9 +178,9 @@ useIntersectionObserver(video, (entries) => {
: 'rounded-1 bg-black/65 text-white hover:bg-black px1.2 py0.2'"
>
<div hidden>
read {{ attachment.type }} description
{{ $t('status.img_alt.read', [attachment.type]) }}
</div>
ALT
{{ $t('status.img_alt.ALT') }}
</button>
<template #popper>
<div p4 flex flex-col gap-2 max-w-130>

View file

@ -26,7 +26,7 @@ const props = withDefaults(
const userSettings = useUserSettings()
const status = $computed(() => {
if (props.status.reblog && !props.status.content)
if (props.status.reblog && (!props.status.content || props.status.content === props.status.reblog.content))
return props.status.reblog
return props.status
})
@ -79,7 +79,8 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
<div
:id="`status-${status.id}`"
ref="el"
relative flex="~ col gap1" p="l-3 r-4 b-2"
relative flex="~ col gap1"
p="b-2 is-3 ie-4"
:class="{ 'hover:bg-active': hover }"
tabindex="0"
focus:outline-none focus-visible:ring="2 primary"
@ -95,12 +96,12 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
<template v-if="status.inReplyToAccountId">
<StatusReplyingTo
v-if="showReplyTo"
ml-20px pt-1 pl-5
m="is-5" p="t-1 is-5"
:status="status"
:is-self-reply="isSelfReply"
:class="faded ? 'text-secondary-light' : ''"
/>
<div flex="~ col gap-1" items-center pos="absolute top-0 left-0" w="77px" z--1>
<div flex="~ col gap-1" items-center pos="absolute top-0 inset-is-0" w="77px" z--1>
<template v-if="showReplyTo">
<div w="1px" h="0.5" border="x base" mt-3 />
<div w="1px" h="0.5" border="x base" />

View file

@ -15,7 +15,11 @@ const filterResult = $computed(() => status.filtered?.length ? status.filtered[0
const filter = $computed(() => filterResult?.filter)
const filterPhrase = $computed(() => filter?.title)
const isFiltered = $computed(() => filterPhrase && (context && context !== 'details' ? filter?.context.includes(context) : false))
const isFiltered = $computed(() => status.account.id !== currentUser.value?.account.id && filterPhrase && context && context !== 'details' && !!filter?.context.includes(context))
// check spoiler text or media attachment
// needed to handle accounts that mark all their posts as sensitive
const hasSensitiveSpoilerOrMedia = $computed(() => status.sensitive && (!!status.spoilerText || !!status.mediaAttachments.length))
const cleanSharedLink = !status.poll
&& !status.mediaAttachments.length
@ -31,13 +35,13 @@ const cleanSharedLink = !status.poll
}"
>
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered" :is-d-m="isDM">
<template v-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<template v-else-if="status.spoilerText" #spoiler>
<StatusSpoiler :enabled="hasSensitiveSpoilerOrMedia || isFiltered" :filter="isFiltered" :is-d-m="isDM">
<template v-if="status.spoilerText" #spoiler>
<p>{{ status.spoilerText }}</p>
</template>
<template v-else-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
</template>
<StatusBody v-if="!status.sensitive || status.spoilerText" :clean-shared-link="cleanSharedLink" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" />

View file

@ -17,6 +17,6 @@ const gitHubCards = $(usePreferences('experimentalGitHubCards'))
<template>
<LazyStatusPreviewGitHub v-if="gitHubCards && providerName === 'GitHub'" :card="card" />
<LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'stackblitz.com'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
<LazyStatusPreviewStackBlitz v-else-if="gitHubCards && providerName === 'StackBlitz'" :card="card" :small-picture-only="smallPictureOnly" :root="root" />
<StatusPreviewCardNormal v-else :card="card" :clean-shared-link="cleanSharedLink" :small-picture-only="smallPictureOnly" :root="root" />
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import reservedNames from 'github-reserved-names'
const props = defineProps<{
card: mastodon.v1.PreviewCard
@ -20,24 +21,22 @@ interface Meta {
}
}
const specialRoutes = ['orgs', 'sponsors', 'stars']
const meta = $computed(() => {
const { url } = props.card
const path = url.split('https://github.com/')[1]
// Supported paths
// /user
// /user/repo
// /user/repo/issues/number
// /user/repo/pull/number
// /orgs/user
// /sponsors/user
// /stars/user
const supportedReservedRoutes = ['sponsors']
const firstName = path.match(/([\w-]+)(\/|$)/)?.[1]
const secondName = path.match(/[\w-]+\/([\w-]+)/)?.[1]
const firstIsUser = firstName && !specialRoutes.includes(firstName)
const meta = $computed(() => {
const { url } = props.card
const path = url.split('https://github.com/')[1]
const [firstName, secondName] = path?.split('/') || []
if (!firstName || (reservedNames.check(firstName) && !supportedReservedRoutes.includes(firstName)))
return undefined
const firstIsUser = firstName && !supportedReservedRoutes.includes(firstName)
const user = firstIsUser ? firstName : secondName
const repo = firstIsUser ? secondName : undefined
@ -86,7 +85,7 @@ const meta = $computed(() => {
<template>
<div
v-if="card.image"
v-if="card.image && meta"
flex flex-col
display-block of-hidden
bg-card
@ -132,4 +131,5 @@ const meta = $computed(() => {
</div>
</div>
</div>
<StatusPreviewCardNormal v-else :card="card" />
</template>

View file

@ -21,9 +21,9 @@ const maxLines = 20
const meta = $computed(() => {
const { description } = props.card
const meta = description.match(/.+\n\nCode Snippet from (.+), lines ([\w-]+)\n\n(.+)/s)
const meta = description.match(/.*Code Snippet from (.+), lines (\S+)\n\n(.+)/s)
const file = meta?.[1]
const lines = meta?.[2].replaceAll('N', '')
const lines = meta?.[2]
const code = meta?.[3].split('\n').slice(0, maxLines).join('\n')
const project = props.card.title?.replace(' - StackBlitz', '')
const info = $ref<Meta>({
@ -38,7 +38,12 @@ const meta = $computed(() => {
const vnodeCode = $computed(() => {
if (!meta.code)
return null
const vnode = contentToVNode(`<p>\`\`\`${meta.file?.split('.')?.[1] ?? ''}\n${meta.code}\n\`\`\`\</p>`, {
const code = meta.code
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;')
const vnode = contentToVNode(`<p>\`\`\`${meta.file?.split('.')?.[1] ?? ''}\n${code}\n\`\`\`\</p>`, {
markdown: true,
})
return vnode

View file

@ -12,7 +12,7 @@ const {
} = useTranslation(status, getLanguageCode())
const preferenceHideTranslation = usePreferences('hideTranslation')
const showButton = computed(() => !preferenceHideTranslation.value && isTranslationEnabled && status.language !== getLanguageCode())
const showButton = computed(() => !preferenceHideTranslation.value && isTranslationEnabled)
let translating = $ref(false)
const toggleTranslation = async () => {

View file

@ -40,6 +40,9 @@ watch(items, () => {
})
function onKeyDown(event: KeyboardEvent) {
if (items.length === 0)
return false
if (event.key === 'ArrowUp') {
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
return true

View file

@ -15,6 +15,9 @@ watch(items, () => {
})
function onKeyDown(event: KeyboardEvent) {
if (items.length === 0)
return false
if (event.key === 'ArrowUp') {
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
return true

View file

@ -15,6 +15,9 @@ watch(items, () => {
})
function onKeyDown(event: KeyboardEvent) {
if (items.length === 0)
return false
if (event.key === 'ArrowUp') {
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
return true

View file

@ -5,7 +5,7 @@ const all = useUsers()
const router = useRouter()
const clickUser = (user: UserLogin) => {
if (user.account.id === currentUser.value?.account.id)
if (user.account.acct === currentUser.value?.account.acct)
router.push(getAccountRoute(user.account))
else
switchUser(user)
@ -21,7 +21,7 @@ const clickUser = (user: UserLogin) => {
flex rounded
cursor-pointer
aria-label="Switch user"
:class="user.account.id === currentUser?.account.id ? '' : 'op25 grayscale'"
:class="user.account.acct === currentUser?.account.acct ? '' : 'op25 grayscale'"
hover="filter-none op100"
@click="clickUser(user)"
>

View file

@ -1,66 +1,21 @@
<script setup lang="ts">
import Fuse from 'fuse.js'
import { $fetch } from 'ofetch'
const input = $ref<HTMLInputElement>()
let server = $ref<string>('')
let busy = $ref<boolean>(false)
let error = $ref<boolean>(false)
let displayError = $ref<boolean>(false)
const input = ref<HTMLInputElement | undefined>()
let knownServers = $ref<string[]>([])
let autocompleteIndex = $ref(0)
let autocompleteShow = $ref(false)
const users = useUsers()
const userSettings = useUserSettings()
async function oauth() {
if (busy)
return
busy = true
error = false
displayError = false
await nextTick()
if (server)
server = server.split('/')[0]
try {
const url = await (globalThis.$fetch as any)(`/api/${server || publicServer.value}/login`, {
method: 'POST',
body: {
force_login: users.value.some(u => u.server === server),
origin: location.origin,
lang: userSettings.value.language,
},
})
location.href = url
}
catch (err) {
console.error(err)
displayError = true
error = true
await nextTick()
input?.focus()
await nextTick()
setTimeout(() => {
busy = false
error = false
}, 512)
}
}
const { busy, error, displayError, server, oauth } = useSignIn(input)
let fuse = $shallowRef(new Fuse([] as string[]))
const filteredServers = $computed(() => {
if (!server)
if (!server.value)
return []
const results = fuse.search(server, { limit: 6 }).map(result => result.item)
if (results[0] === server)
const results = fuse.search(server.value, { limit: 6 }).map(result => result.item)
if (results[0] === server.value)
return []
return results
@ -78,12 +33,12 @@ function isValidUrl(str: string) {
}
async function handleInput() {
const input = server.trim()
const input = server.value.trim()
if (input.startsWith('https://'))
server = input.replace('https://', '')
server.value = input.replace('https://', '')
if (input.length)
displayError = false
displayError.value = false
if (
isValidUrl(`https://${input}`)
@ -110,7 +65,7 @@ function move(delta: number) {
function onEnter(e: KeyboardEvent) {
if (autocompleteShow === true && filteredServers[autocompleteIndex]) {
server = filteredServers[autocompleteIndex]
server.value = filteredServers[autocompleteIndex]
e.preventDefault()
autocompleteShow = false
}
@ -124,16 +79,16 @@ function escapeAutocomplete(evt: KeyboardEvent) {
}
function select(index: number) {
server = filteredServers[index]
server.value = filteredServers[index]
}
onMounted(async () => {
input?.focus()
input?.value?.focus()
knownServers = await (globalThis.$fetch as any)('/api/list-servers')
fuse = new Fuse(knownServers, { shouldSort: true })
})
onClickOutside($$(input), () => {
onClickOutside(input, () => {
autocompleteShow = false
})
</script>

View file

@ -1,3 +1,7 @@
<script setup lang="ts">
const { busy, oauth, singleInstanceServer } = useSignIn()
</script>
<template>
<div p8 lg:flex="~ col gap2" hidden>
<p v-if="isHydrated" text-sm>
@ -8,7 +12,19 @@
<p text-sm text-secondary>
{{ $t('user.sign_in_desc') }}
</p>
<button btn-solid rounded-3 text-center mt-2 select-none @click="openSigninDialog()">
<button
v-if="singleInstanceServer"
flex="~ row" gap-x-2 items-center justify-center btn-solid text-center rounded-3
:disabled="busy"
@click="oauth()"
>
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
{{ $t('action.sign_in') }}
</button>
<button v-else btn-solid rounded-3 text-center mt-2 select-none @click="openSigninDialog()">
{{ $t('action.sign_in') }}
</button>
</div>

View file

@ -6,6 +6,7 @@ const emit = defineEmits<{
}>()
const all = useUsers()
const { busy, singleInstanceServer, oauth } = useSignIn()
const sorted = computed(() => {
return [
@ -21,6 +22,12 @@ const clickUser = (user: UserLogin) => {
else
switchUser(user)
}
const processSignIn = () => {
if (singleInstanceServer)
oauth()
else
openSigninDialog()
}
</script>
<template>
@ -43,7 +50,7 @@ const clickUser = (user: UserLogin) => {
:text="$t('user.add_existing')"
icon="i-ri:user-add-line"
w-full
@click="openSigninDialog"
@click="processSignIn"
/>
<CommonDropdownItem
is="button"
@ -51,7 +58,7 @@ const clickUser = (user: UserLogin) => {
:text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])"
icon="i-ri:logout-box-line rtl-flip"
w-full
@click="signout"
@click="signOut"
/>
</div>
</div>

View file

@ -7,7 +7,7 @@ export interface Team {
mastodon: string
}
export const teams: Team[] = [
export const elkTeamMembers: Team[] = [
{
github: 'antfu',
display: 'Anthony Fu',
@ -35,5 +35,5 @@ export const teams: Team[] = [
].sort(() => Math.random() - 0.5)
export function useBuildInfo() {
return useRuntimeConfig().public.buildInfo as BuildInfo
return useAppConfig().buildInfo as BuildInfo
}

View file

@ -59,7 +59,8 @@ export function fetchAccountById(id?: string | null): Promise<mastodon.v1.Accoun
export async function fetchAccountByHandle(acct: string): Promise<mastodon.v1.Account> {
const server = currentServer.value
const userId = currentUser.value?.account.id
const key = `${server}:${userId}:account:${acct}`
const userAcct = acct.endsWith(`@${server}`) ? acct.slice(0, -server.length - 1) : acct
const key = `${server}:${userId}:account:${userAcct}`
const cached = cache.get(key)
if (cached)
return cached
@ -69,9 +70,9 @@ export async function fetchAccountByHandle(acct: string): Promise<mastodon.v1.Ac
const client = useMastoClient()
let account: mastodon.v1.Account
if (!isGotoSocial.value)
account = await client.v1.accounts.lookup({ acct })
account = await client.v1.accounts.lookup({ acct: userAcct })
else
account = (await client.v1.search({ q: `@${acct}`, type: 'accounts' })).accounts[0]
account = (await client.v1.search({ q: `@${userAcct}`, type: 'accounts' })).accounts[0]
if (account.acct && !account.acct.includes('@') && domain)
account.acct = `${account.acct}@${domain}`
@ -107,6 +108,7 @@ export function removeCachedStatus(id: string, server = currentServer.value) {
export function cacheAccount(account: mastodon.v1.Account, server = currentServer.value, override?: boolean) {
const userId = currentUser.value?.account.id
const userAcct = account.acct.endsWith(`@${server}`) ? account.acct.slice(0, -server.length - 1) : account.acct
setCached(`${server}:${userId}:account:${account.id}`, account, override)
setCached(`${server}:${userId}:account:${account.acct}`, account, override)
setCached(`${server}:${userId}:account:${userAcct}`, account, override)
}

View file

@ -245,6 +245,7 @@ export const provideGlobalCommands = () => {
const masto = useMasto()
const colorMode = useColorMode()
const userSettings = useUserSettings()
const { singleInstanceServer, oauth } = useSignIn()
useCommand({
scope: 'Navigation',
@ -310,6 +311,9 @@ export const provideGlobalCommands = () => {
icon: 'i-ri:user-add-line',
onActivate() {
if (singleInstanceServer)
oauth()
else
openSigninDialog()
},
})
@ -349,7 +353,7 @@ export const provideGlobalCommands = () => {
icon: 'i-ri:logout-box-line',
onActivate() {
signout()
signOut()
},
})
}

View file

@ -8,6 +8,7 @@ import { emojiRegEx, getEmojiAttributes } from '../config/emojis'
export interface ContentParseOptions {
emojis?: Record<string, mastodon.v1.CustomEmoji>
hideEmojis?: boolean
mentions?: mastodon.v1.StatusMention[]
markdown?: boolean
replaceUnicodeEmoji?: boolean
@ -82,6 +83,7 @@ export function parseMastodonHTML(
replaceUnicodeEmoji = true,
convertMentionLink = false,
collapseMentionLink = false,
hideEmojis = false,
mentions,
status,
inReplyToStatus,
@ -110,9 +112,17 @@ export function parseMastodonHTML(
...options.astTransforms || [],
]
if (hideEmojis) {
transforms.push(removeUnicodeEmoji)
transforms.push(removeCustomEmoji(options.emojis ?? {}))
}
else {
if (replaceUnicodeEmoji)
transforms.push(transformUnicodeEmoji)
transforms.push(replaceCustomEmoji(options.emojis ?? {}))
}
if (markdown)
transforms.push(transformMarkdown)
@ -122,8 +132,6 @@ export function parseMastodonHTML(
if (convertMentionLink)
transforms.push(transformMentionLink)
transforms.push(replaceCustomEmoji(options.emojis || {}))
transforms.push(transformParagraphs)
if (collapseMentionLink)
@ -353,6 +361,25 @@ function filterHref() {
}
}
function removeUnicodeEmoji(node: Node) {
if (node.type !== TEXT_NODE)
return node
let start = 0
const matches = [] as (string | Node)[]
findAndReplaceEmojisInText(emojiRegEx, node.value, (match, result) => {
matches.push(result.slice(start).trimEnd())
start = result.length + match.match.length
return undefined
})
if (matches.length === 0)
return node
matches.push(node.value.slice(start))
return matches.filter(Boolean)
}
function transformUnicodeEmoji(node: Node) {
if (node.type !== TEXT_NODE)
return node
@ -374,6 +401,28 @@ function transformUnicodeEmoji(node: Node) {
return matches.filter(Boolean)
}
function removeCustomEmoji(customEmojis: Record<string, mastodon.v1.CustomEmoji>): Transform {
return (node) => {
if (node.type !== TEXT_NODE)
return node
const split = node.value.split(/\s?:([\w-]+?):/g)
if (split.length === 1)
return node
return split.map((name, i) => {
if (i % 2 === 0)
return name
const emoji = customEmojis[name] as mastodon.v1.CustomEmoji
if (!emoji)
return `:${name}:`
return ''
}).filter(Boolean)
}
}
function replaceCustomEmoji(customEmojis: Record<string, mastodon.v1.CustomEmoji>): Transform {
return (node) => {
if (node.type !== TEXT_NODE)

View file

@ -10,6 +10,14 @@ import ContentCode from '~/components/content/ContentCode.vue'
import ContentMentionGroup from '~/components/content/ContentMentionGroup.vue'
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
function getTexualAstComponents(astChildren: Node[]): string {
return astChildren
.filter(({ type }) => type === TEXT_NODE)
.map(({ value }) => value)
.reduce((accumulator, current) => accumulator + current, '')
.trim()
}
/**
* Raw HTML to VNodes
*/
@ -17,11 +25,18 @@ export function contentToVNode(
content: string,
options?: ContentParseOptions,
): VNode {
const tree = parseMastodonHTML(content, options)
let tree = parseMastodonHTML(content, options)
const textContents = getTexualAstComponents(tree.children)
// if the username only contains emojis, we should probably show the emojis anyway to avoid a blank name
if (options?.hideEmojis && textContents.length === 0)
tree = parseMastodonHTML(content, { ...options, hideEmojis: false })
return h(Fragment, (tree.children as Node[] || []).map(n => treeToVNode(n)))
}
export function nodeToVNode(node: Node): VNode | string | null {
function nodeToVNode(node: Node): VNode | string | null {
if (node.type === TEXT_NODE)
return node.value

View file

@ -1,9 +1,10 @@
import type { mastodon } from 'masto'
import type { ConfirmDialogChoice, ConfirmDialogLabel, Draft } from '~/types'
import type { ConfirmDialogChoice, ConfirmDialogLabel, Draft, ErrorDialogData } from '~/types'
import { STORAGE_KEY_FIRST_VISIT } from '~/constants'
export const confirmDialogChoice = ref<ConfirmDialogChoice>()
export const confirmDialogLabel = ref<ConfirmDialogLabel>()
export const errorDialogData = ref<ErrorDialogData>()
export const mediaPreviewList = ref<mastodon.v1.MediaAttachment[]>([])
export const mediaPreviewIndex = ref(0)
@ -22,6 +23,7 @@ export const isEditHistoryDialogOpen = ref(false)
export const isPreviewHelpOpen = ref(isFirstVisit.value)
export const isCommandPanelOpen = ref(false)
export const isConfirmDialogOpen = ref(false)
export const isErrorDialogOpen = ref(false)
export const isFavouritedBoostedByDialogOpen = ref(false)
export const lastPublishDialogStatus = ref<mastodon.v1.Status | null>(null)
@ -101,6 +103,17 @@ export function openMediaPreview(attachments: mastodon.v1.MediaAttachment[], ind
}, '')
}
export async function openErrorDialog(data: ErrorDialogData) {
errorDialogData.value = data
isErrorDialogOpen.value = true
await until(isErrorDialogOpen).toBe(false)
}
export function closeErrorDialog() {
isErrorDialogOpen.value = false
}
export function closeMediaPreview() {
history.back()
}

View file

@ -1,76 +0,0 @@
import type { PermissiveMotionProperties } from '@vueuse/motion'
import type { Handlers } from '@vueuse/gesture'
import { useMotionProperties, useSpring } from '@vueuse/motion'
import { useGesture } from '@vueuse/gesture'
import type { MaybeRef } from '@vueuse/core'
export interface CarouselOptions {
hasNext: MaybeRef<boolean>
hasPrev: MaybeRef<boolean>
onPrev: () => void
onNext: () => void
}
export const useImageGesture = (
domTarget: MaybeRef<HTMLElement>,
carouselOptions?: CarouselOptions,
) => {
const { motionProperties } = useMotionProperties(domTarget, {
cursor: 'grab',
scale: 1,
x: 0,
y: 0,
})
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
const handlers: Handlers = {
onPinch({ offset: [d] }) {
set({ scale: 1 + d / 200 })
},
onDragStart() {
set({ cursor: 'grabbing' })
},
onDrag({ movement: [x, y], pinching }) {
if (!pinching)
set({ x, y, cursor: 'grabbing' })
},
onDragEnd({ vxvy: [vx], pinching }) {
if (pinching)
return
set({ cursor: 'grab' })
if (carouselOptions) {
const isSwipe = Math.abs(vx) > 0.25
if (isSwipe) {
if (vx > 0 && unref(carouselOptions.hasPrev))
carouselOptions.onPrev()
else if (vx < 0 && unref(carouselOptions.hasNext))
carouselOptions.onNext()
}
}
set({ x: 0, y: 0 })
},
onMove({ movement: [x, y], dragging, pinching }) {
if (dragging && !pinching)
set({ x, y })
},
onWheel({ event, dragging, pinching }) {
if (!dragging && !pinching && event.altKey) {
event.preventDefault()
// @ts-expect-error why is ts complaining here (motionProperties.scale)?
set({ scale: motionProperties.scale + event.deltaY * 0.001 })
}
},
onDblclick() {
set({ scale: 1 })
},
onTouchstart(event) {
if (event.touches === 2)
set({ scale: 1 })
},
}
useGesture(handlers, { domTarget })
}

11
composables/langugage.ts Normal file
View file

@ -0,0 +1,11 @@
import ISO6391 from 'iso-639-1'
export const languagesNameList: {
code: string
nativeName: string
name: string
}[] = ISO6391.getAllCodes().map(code => ({
code,
nativeName: ISO6391.getNativeName(code),
name: ISO6391.getName(code),
}))

View file

@ -7,14 +7,14 @@ export function getDisplayName(account: mastodon.v1.Account, options?: { rich?:
return displayName.replace(/:([\w-]+?):/g, '')
}
export function acctToShortHandle(acct: string) {
export function accountToShortHandle(acct: string) {
return `@${acct.includes('@') ? acct.split('@')[0] : acct}`
}
export function getShortHandle({ acct }: mastodon.v1.Account) {
if (!acct)
return ''
return acctToShortHandle(acct)
return accountToShortHandle(acct)
}
export function getServerName(account: mastodon.v1.Account) {

View file

@ -47,7 +47,7 @@ export function mastoLogin(masto: ElkMasto, user: Pick<UserLogin, 'server' | 'to
setParams({
streamingApiUrl: newInstance.urls.streamingApi,
})
instances.value[server] = newInstance
instanceStorage.value[server] = newInstance
})
return instance

View file

@ -4,20 +4,34 @@ import type { mastodon } from 'masto'
import type { UseDraft } from './statusDrafts'
import type { Draft } from '~~/types'
export const usePublish = (options: {
export function usePublish(options: {
draftState: UseDraft
expanded: Ref<boolean>
isUploading: Ref<boolean>
initialDraft: Ref<() => Draft>
}) => {
}) {
const { expanded, isUploading, initialDraft } = $(options)
let { draft, isEmpty } = $(options.draftState)
const { client } = $(useMasto())
const settings = useUserSettings()
const preferredLanguage = $computed(() => (settings.value?.language || 'en').split('-')[0])
let isSending = $ref(false)
const isExpanded = $ref(false)
const failedMessages = $ref<string[]>([])
const publishSpoilerText = $computed({
get() {
return draft.params.sensitive ? draft.params.spoilerText : ''
},
set(val) {
if (!draft.params.sensitive)
return
draft.params.spoilerText = val
},
})
const shouldExpanded = $computed(() => expanded || isExpanded || !isEmpty)
const isPublishDisabled = $computed(() => {
return isEmpty || isUploading || isSending || (draft.attachments.length === 0 && !draft.params.status) || failedMessages.length > 0
@ -29,14 +43,19 @@ export const usePublish = (options: {
}, { deep: true })
async function publishDraft() {
if (isPublishDisabled)
return
let content = htmlToText(draft.params.status || '')
if (draft.mentions?.length)
content = `${draft.mentions.map(i => `@${i}`).join(' ')} ${content}`
const payload = {
...draft.params,
spoilerText: publishSpoilerText,
status: content,
mediaIds: draft.attachments.map(a => a.id),
language: draft.params.language || preferredLanguage,
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
} as mastodon.v1.CreateStatusParams
@ -58,6 +77,7 @@ export const usePublish = (options: {
let status: mastodon.v1.Status
if (!draft.editingStatus)
status = await client.v1.statuses.create(payload)
else
status = await client.v1.statuses.update(draft.editingStatus.id, payload)
if (draft.params.inReplyToId)
@ -82,14 +102,15 @@ export const usePublish = (options: {
shouldExpanded,
isPublishDisabled,
failedMessages,
preferredLanguage,
publishSpoilerText,
publishDraft,
})
}
export type MediaAttachmentUploadError = [filename: string, message: string]
export const useUploadMediaAttachment = (draftRef: Ref<Draft>) => {
export function useUploadMediaAttachment(draftRef: Ref<Draft>) {
const draft = $(draftRef)
const { client } = $(useMasto())
const { t } = useI18n()
@ -157,9 +178,10 @@ export const useUploadMediaAttachment = (draftRef: Ref<Draft>) => {
return $$({
isUploading,
isExceedingAttachmentLimit,
isOverDropZone,
failedAttachments,
dropZoneRef,
isOverDropZone,
uploadAttachments,
pickAttachments,

View file

@ -33,7 +33,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
visibility: visibility || 'public',
sensitive: sensitive ?? false,
spoilerText: spoilerText || '',
language: language || getDefaultLanguage(),
language: language || '', // auto inferred from current language on posting
},
mentions,
lastUpdated: Date.now(),
@ -52,16 +52,6 @@ export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<Dr
})
}
function getDefaultLanguage() {
const userSettings = useUserSettings()
const defaultLanguage = userSettings.value.language
if (defaultLanguage)
return defaultLanguage.split('-')[0]
return 'en'
}
function getAccountsToMention(status: mastodon.v1.Status) {
const userId = currentUser.value?.account.id
const accountsToMention = new Set<string>()
@ -84,6 +74,7 @@ export function getReplyDraft(status: mastodon.v1.Status) {
inReplyToId: status!.id,
visibility: status.visibility,
mentions: accountsToMention,
language: status.language,
})
},
}
@ -98,7 +89,6 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
return (text.length === 0)
&& attachments.length === 0
&& (params.spoilerText || '').length === 0
}
export interface UseDraft {

View file

@ -8,6 +8,40 @@ export interface TranslationResponse {
}
}
// @see https://github.com/LibreTranslate/LibreTranslate/tree/main/libretranslate/locales
export const supportedTranslationCodes = [
'ar',
'az',
'cs',
'da',
'de',
'el',
'en',
'eo',
'es',
'fa',
'fi',
'fr',
'ga',
'he',
'hi',
'hu',
'id',
'it',
'ja',
'ko',
'nl',
'pl',
'pt',
'ru',
'sk',
'sv',
'tr',
'uk',
'vi',
'zh',
] as const
export const getLanguageCode = () => {
let code = 'en'
const getCode = (code: string) => code.replace(/-.*$/, '')
@ -63,9 +97,16 @@ export function useTranslation(status: mastodon.v1.Status | mastodon.v1.StatusEd
translations.set(status, reactive({ visible: false, text: '', success: false, error: '' }))
const translation = translations.get(status)!
const userSettings = useUserSettings()
const shouldTranslate = 'language' in status && status.language && status.language !== to
&& supportedTranslationCodes.includes(to as any)
&& supportedTranslationCodes.includes(status.language as any)
&& !userSettings.value.disabledTranslationLanguages.includes(status.language)
const enabled = /*! !useRuntimeConfig().public.translateApi && */ shouldTranslate
async function toggle() {
if (!('language' in status))
if (!shouldTranslate)
return
if (!translation.text) {
@ -79,7 +120,7 @@ export function useTranslation(status: mastodon.v1.Status | mastodon.v1.StatusEd
}
return {
enabled: !!useRuntimeConfig().public.translateApi,
enabled,
toggle,
translation,
}

View file

@ -46,9 +46,9 @@ export const createPushSubscription = async (
if (error.code === 11 && error.name === 'InvalidStateError')
useError = new PushSubscriptionError('too_many_registrations', 'Too many registrations')
else if (error.code === 20 && error.name === 'AbortError')
console.error('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.')
useError = new PushSubscriptionError('vapid_not_supported', 'Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.')
else if (error.code === 5 && error.name === 'InvalidCharacterError')
console.error('The VAPID public key seems to be invalid:', vapidKey)
useError = new PushSubscriptionError('invalid_vapid_key', `The VAPID public key seems to be invalid: ${vapidKey}`)
return getRegistration()
.then(getPushSubscription)

View file

@ -25,7 +25,8 @@ export interface CustomEmojisInfo {
emojis: mastodon.v1.CustomEmoji[]
}
export type PushSubscriptionErrorCode = 'too_many_registrations'
export type PushSubscriptionErrorCode = 'too_many_registrations' | 'vapid_not_supported' | 'invalid_vapid_key'
export class PushSubscriptionError extends Error {
code: PushSubscriptionErrorCode
constructor(code: PushSubscriptionErrorCode, message?: string) {

View file

@ -1,4 +1,5 @@
import type { mastodon } from 'masto'
import type {
CreatePushNotification,
PushNotificationPolicy,
@ -27,19 +28,17 @@ export const usePushManager = () => {
const isSupported = $computed(() => supportsPushNotifications)
const hiddenNotification = useLocalStorage<PushNotificationRequest>(STORAGE_KEY_NOTIFICATION, {})
const configuredPolicy = useLocalStorage<PushNotificationPolicy>(STORAGE_KEY_NOTIFICATION_POLICY, {})
const pushNotificationData = ref({
follow: currentUser.value?.pushSubscription?.alerts.follow ?? true,
favourite: currentUser.value?.pushSubscription?.alerts.favourite ?? true,
reblog: currentUser.value?.pushSubscription?.alerts.reblog ?? true,
mention: currentUser.value?.pushSubscription?.alerts.mention ?? true,
poll: currentUser.value?.pushSubscription?.alerts.poll ?? true,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
})
// don't clone, we're using indexeddb
const { history, commit, clear } = useManualRefHistory(pushNotificationData)
const pushNotificationData = ref(createRawSettings(
currentUser.value?.pushSubscription,
configuredPolicy.value[currentUser.value?.account?.acct ?? ''],
))
const oldPushNotificationData = ref(createRawSettings(
currentUser.value?.pushSubscription,
configuredPolicy.value[currentUser.value?.account?.acct ?? ''],
))
const saveEnabled = computed(() => {
const current = pushNotificationData.value
const previous = history.value?.[0]?.snapshot
const previous = oldPushNotificationData.value
return current.favourite !== previous.favourite
|| current.reblog !== previous.reblog
|| current.mention !== previous.mention
@ -50,14 +49,14 @@ export const usePushManager = () => {
watch(() => currentUser.value?.pushSubscription, (subscription) => {
isSubscribed.value = !!subscription
pushNotificationData.value = {
follow: subscription?.alerts.follow ?? false,
favourite: subscription?.alerts.favourite ?? false,
reblog: subscription?.alerts.reblog ?? false,
mention: subscription?.alerts.mention ?? false,
poll: subscription?.alerts.poll ?? false,
policy: configuredPolicy.value[currentUser.value?.account?.acct ?? ''] ?? 'all',
}
pushNotificationData.value = createRawSettings(
subscription,
configuredPolicy.value[currentUser.value?.account?.acct ?? ''],
)
oldPushNotificationData.value = createRawSettings(
subscription,
configuredPolicy.value[currentUser.value?.account?.acct ?? ''],
)
}, { immediate: true, flush: 'post' })
const subscribe = async (
@ -121,7 +120,15 @@ export const usePushManager = () => {
if (policy)
pushNotificationData.value.policy = policy
commit()
const current = pushNotificationData.value
oldPushNotificationData.value = {
favourite: current.favourite,
reblog: current.reblog,
mention: current.mention,
follow: current.follow,
poll: current.poll,
policy: current.policy,
}
if (policy)
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = policy
@ -129,27 +136,25 @@ export const usePushManager = () => {
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = pushNotificationData.value.policy
await nextTick()
clear()
await nextTick()
}
const undoChanges = () => {
const current = pushNotificationData.value
const previous = history.value[0].snapshot
current.favourite = previous.favourite
current.reblog = previous.reblog
current.mention = previous.mention
current.follow = previous.follow
current.poll = previous.poll
current.policy = previous.policy
const previous = oldPushNotificationData.value
pushNotificationData.value = {
favourite: previous.favourite,
reblog: previous.reblog,
mention: previous.mention,
follow: previous.follow,
poll: previous.poll,
policy: previous.policy,
}
configuredPolicy.value[currentUser.value!.account.acct ?? ''] = previous.policy
commit()
clear()
}
const updateSubscription = async () => {
if (currentUser.value) {
const previous = history.value[0].snapshot
const previous = oldPushNotificationData.value
// const previous = history.value[0].snapshot
const data = {
alerts: {
follow: pushNotificationData.value.follow,
@ -190,3 +195,17 @@ export const usePushManager = () => {
unsubscribe,
}
}
function createRawSettings(
pushSubscription?: mastodon.v1.WebPushSubscription,
subscriptionPolicy?: mastodon.v1.SubscriptionPolicy,
) {
return {
follow: pushSubscription?.alerts.follow ?? true,
favourite: pushSubscription?.alerts.favourite ?? true,
reblog: pushSubscription?.alerts.reblog ?? true,
mention: pushSubscription?.alerts.mention ?? true,
poll: pushSubscription?.alerts.poll ?? true,
policy: subscriptionPolicy ?? 'all',
}
}

View file

@ -2,6 +2,5 @@ import { breakpointsTailwind } from '@vueuse/core'
export const breakpoints = useBreakpoints(breakpointsTailwind)
export const isSmallScreen = breakpoints.smallerOrEqual('md')
export const isMediumScreen = breakpoints.smallerOrEqual('lg')
export const isMediumOrLargeScreen = breakpoints.between('sm', 'xl')
export const isExtraLargeScreen = breakpoints.smallerOrEqual('xl')

View file

@ -8,14 +8,17 @@ export type OldFontSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ColorMode = 'light' | 'dark' | 'system'
export interface PreferencesSettings {
hideAltIndicatorOnPosts: boolean
hideBoostCount: boolean
hideReplyCount: boolean
hideFavoriteCount: boolean
hideFollowerCount: boolean
hideTranslation: boolean
hideUsernameEmojis: boolean
hideAccountHoverCard: boolean
grayscaleMode: boolean
enableAutoplay: boolean
enablePinchToZoom: boolean
experimentalVirtualScroller: boolean
experimentalGitHubCards: boolean
experimentalUserPicker: boolean
@ -26,6 +29,7 @@ export interface UserSettings {
colorMode?: ColorMode
fontSize: FontSize
language: string
disabledTranslationLanguages: string[]
zenMode: boolean
themeColors?: ThemeColors
}
@ -56,20 +60,24 @@ export function getDefaultUserSettings(locales: string[]): UserSettings {
return {
language: getDefaultLanguage(locales),
fontSize: DEFAULT_FONT_SIZE,
disabledTranslationLanguages: [],
zenMode: false,
preferences: {},
}
}
export const DEFAULT__PREFERENCES_SETTINGS: PreferencesSettings = {
hideAltIndicatorOnPosts: false,
hideBoostCount: false,
hideReplyCount: false,
hideFavoriteCount: false,
hideFollowerCount: false,
hideTranslation: false,
hideUsernameEmojis: false,
hideAccountHoverCard: false,
grayscaleMode: false,
enableAutoplay: true,
enablePinchToZoom: false,
experimentalVirtualScroller: true,
experimentalGitHubCards: true,
experimentalUserPicker: true,

View file

@ -5,6 +5,7 @@ export function setupPageHeader() {
const { locale, locales, t } = useI18n()
const colorMode = useColorMode()
const buildInfo = useBuildInfo()
const enablePinchToZoom = usePreferences('enablePinchToZoom')
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
acc[l.code!] = l.dir ?? 'auto'
@ -15,7 +16,12 @@ export function setupPageHeader() {
htmlAttrs: {
lang: () => locale.value,
dir: () => localeMap[locale.value] ?? 'auto',
class: () => enablePinchToZoom.value ? ['enable-pinch-to-zoom'] : [],
},
meta: [{
name: 'viewport',
content: () => `width=device-width,initial-scale=1${enablePinchToZoom.value ? '' : ',maximum-scale=1,user-scalable=0'},viewport-fit=cover`,
}],
titleTemplate: (title) => {
let titleTemplate = title ?? ''
@ -43,7 +49,7 @@ export function setupPageHeader() {
return titleTemplate
},
link: process.client && useRuntimeConfig().public.pwaEnabled
link: process.client && useAppConfig().pwaEnabled
? () => [{
key: 'webmanifest',
rel: 'manifest',

77
composables/sign-in.ts Normal file
View file

@ -0,0 +1,77 @@
import type { Ref } from 'vue'
export const useSignIn = (input?: Ref<HTMLInputElement | undefined>) => {
const singleInstanceServer = useAppConfig().singleInstanceServer
const userSettings = useUserSettings()
const users = useUsers()
const { t } = useI18n()
const busy = ref(false)
const error = ref(false)
const server = ref('')
const displayError = ref(false)
async function oauth() {
if (busy.value)
return
busy.value = true
error.value = false
displayError.value = false
await nextTick()
if (!singleInstanceServer && server.value)
server.value = server.value.split('/')[0]
try {
let href: string
if (singleInstanceServer) {
href = await (globalThis.$fetch as any)(`/api/${publicServer.value}/login`, {
method: 'POST',
body: {
force_login: users.value.length > 0,
origin: location.origin,
lang: userSettings.value.language,
},
})
busy.value = false
}
else {
href = await (globalThis.$fetch as any)(`/api/${server.value || publicServer.value}/login`, {
method: 'POST',
body: {
force_login: users.value.some(u => u.server === server.value),
origin: location.origin,
lang: userSettings.value.language,
},
})
}
location.href = href
}
catch (err) {
if (singleInstanceServer) {
console.error(err)
busy.value = false
await openErrorDialog({
title: t('common.error'),
messages: [t('error.sign_in_error')],
close: t('action.close'),
})
}
else {
displayError.value = true
error.value = true
await nextTick()
input?.value?.focus()
await nextTick()
setTimeout(() => {
busy.value = false
error.value = false
}, 512)
}
}
}
return { busy, displayError, error, server, singleInstanceServer, oauth }
}

View file

@ -11,7 +11,7 @@ function areStatusesConsecutive(a: mastodon.v1.Status, b: mastodon.v1.Status) {
function removeFilteredItems(items: mastodon.v1.Status[], context: mastodon.v1.FilterContext): mastodon.v1.Status[] {
const isStrict = (filter: mastodon.v1.FilterResult) => filter.filter.filterAction === 'hide' && filter.filter.context.includes(context)
const isFiltered = (item: mastodon.v1.Status) => !item.filtered?.find(isStrict)
const isFiltered = (item: mastodon.v1.Status) => (item.account.id === currentUser.value?.account.id) || !item.filtered?.find(isStrict)
const isReblogFiltered = (item: mastodon.v1.Status) => !item.reblog?.filtered?.find(isStrict)
return [...items].filter(isFiltered).filter(isReblogFiltered)

View file

@ -7,7 +7,6 @@ import type { UserLogin } from '~/types'
import type { Overwrite } from '~/types/utils'
import {
DEFAULT_POST_CHARS_LIMIT,
STORAGE_KEY_CURRENT_USER,
STORAGE_KEY_CURRENT_USER_HANDLE,
STORAGE_KEY_NODES,
STORAGE_KEY_NOTIFICATION,
@ -44,20 +43,20 @@ const initializeUsers = async (): Promise<Ref<UserLogin[]> | RemovableRef<UserLo
}
const users = await initializeUsers()
export const instances = useLocalStorage<Record<string, mastodon.v1.Instance>>(STORAGE_KEY_SERVERS, mock ? mock.server : {}, { deep: true })
export const nodes = useLocalStorage<Record<string, any>>(STORAGE_KEY_NODES, {}, { deep: true })
const currentUserId = useLocalStorage<string>(STORAGE_KEY_CURRENT_USER, mock ? mock.user.account.id : '')
const nodes = useLocalStorage<Record<string, any>>(STORAGE_KEY_NODES, {}, { deep: true })
const currentUserHandle = useLocalStorage<string>(STORAGE_KEY_CURRENT_USER_HANDLE, mock ? mock.user.account.id : '')
export const instanceStorage = useLocalStorage<Record<string, mastodon.v1.Instance>>(STORAGE_KEY_SERVERS, mock ? mock.server : {}, { deep: true })
export type ElkInstance = Partial<mastodon.v1.Instance> & {
uri: string
/** support GoToSocial */
accountDomain?: string | null
}
export const getInstanceCache = (server: string): mastodon.v1.Instance | undefined => instances.value[server]
export const getInstanceCache = (server: string): mastodon.v1.Instance | undefined => instanceStorage.value[server]
export const currentUser = computed<UserLogin | undefined>(() => {
if (currentUserId.value) {
const user = users.value.find(user => user.account?.id === currentUserId.value)
if (currentUserHandle.value) {
const user = users.value.find(user => user.account?.acct === currentUserHandle.value)
if (user)
return user
}
@ -66,7 +65,7 @@ export const currentUser = computed<UserLogin | undefined>(() => {
})
const publicInstance = ref<ElkInstance | null>(null)
export const currentInstance = computed<null | ElkInstance>(() => currentUser.value ? instances.value[currentUser.value.server] ?? null : publicInstance.value)
export const currentInstance = computed<null | ElkInstance>(() => currentUser.value ? instanceStorage.value[currentUser.value.server] ?? null : publicInstance.value)
export function getInstanceDomain(instance: ElkInstance) {
return instance.accountDomain || withoutProtocol(instance.uri)
@ -84,12 +83,12 @@ if (process.client) {
const windowReload = () => {
document.visibilityState === 'visible' && window.location.reload()
}
watch(currentUserId, async (id, oldId) => {
watch(currentUserHandle, async (handle, oldHandle) => {
// when sign in or switch account
if (id) {
if (id === currentUser.value?.account?.id) {
if (handle) {
if (handle === currentUser.value?.account?.acct) {
// when sign in, the other tab will not have the user, idb is not reactive
const newUser = users.value.find(user => user.account?.id === id)
const newUser = users.value.find(user => user.account?.acct === handle)
// if the user is there, then we are switching account
if (newUser) {
// check if the change is on current tab: if so, don't reload
@ -101,19 +100,13 @@ if (process.client) {
window.addEventListener('visibilitychange', windowReload, { capture: true })
}
// when sign out
else if (oldId) {
const oldUser = users.value.find(user => user.account?.id === oldId)
else if (oldHandle) {
const oldUser = users.value.find(user => user.account?.acct === oldHandle)
// when sign out, the other tab will not have the user, idb is not reactive
if (oldUser)
window.addEventListener('visibilitychange', windowReload, { capture: true })
}
}, { immediate: true, flush: 'post' })
// for injected script to read
const currentUserHandle = computed(() => currentUser.value?.account.acct || '')
watchEffect(() => {
localStorage.setItem(STORAGE_KEY_CURRENT_USER_HANDLE, currentUserHandle.value)
})
}
export const useUsers = () => users
@ -144,12 +137,12 @@ export async function loginTo(masto: ElkMasto, user: Overwrite<UserLogin, { acco
const account = getUser()?.account
if (account)
currentUserId.value = account.id
currentUserHandle.value = account.acct
const [me, pushSubscription] = await Promise.all([
fetchAccountInfo(client, user.server),
// if PWA is not enabled, don't get push subscription
useRuntimeConfig().public.pwaEnabled
useAppConfig().pwaEnabled
// we get 404 response instead empty data
? client.v1.webPushSubscriptions.fetch().catch(() => Promise.resolve(undefined))
: Promise.resolve(undefined),
@ -168,7 +161,7 @@ export async function loginTo(masto: ElkMasto, user: Overwrite<UserLogin, { acco
})
}
currentUserId.value = me.id
currentUserHandle.value = me.acct
}
export async function fetchAccountInfo(client: mastodon.Client, server: string) {
@ -194,7 +187,7 @@ export async function removePushNotificationData(user: UserLogin, fromSWPushMana
// clear push notification policy
delete useLocalStorage<PushNotificationPolicy>(STORAGE_KEY_NOTIFICATION_POLICY, {}).value[acct]
const pwaEnabled = useRuntimeConfig().public.pwaEnabled
const pwaEnabled = useAppConfig().pwaEnabled
const pwa = useNuxtApp().$pwa
const registrationError = pwa?.registrationError === true
const unregister = pwaEnabled && !registrationError && pwa?.registrationError === true && fromSWPushManager
@ -238,7 +231,7 @@ export async function switchUser(user: UserLogin) {
}
}
export async function signout() {
export async function signOut() {
// TODO: confirm
if (!currentUser.value)
return
@ -253,24 +246,24 @@ export async function signout() {
// Clear stale data
clearUserLocalStorage()
if (!users.value.some((u, i) => u.server === currentUser.value!.server && i !== index))
delete instances.value[currentUser.value.server]
delete instanceStorage.value[currentUser.value.server]
await removePushNotifications(currentUser.value)
await removePushNotificationData(currentUser.value)
currentUserId.value = ''
currentUserHandle.value = ''
// Remove the current user from the users
users.value.splice(index, 1)
}
// Set currentUserId to next user if available
currentUserId.value = users.value[0]?.account?.id
currentUserHandle.value = users.value[0]?.account?.acct
if (!currentUserId.value)
if (!currentUserHandle.value)
await useRouter().push('/')
loginTo(masto, currentUser.value)
loginTo(masto, currentUser.value || { server: publicServer.value })
}
export function checkLogin() {

View file

@ -7,7 +7,6 @@ export const STORAGE_KEY_DRAFTS = 'elk-drafts'
export const STORAGE_KEY_USERS = 'elk-users'
export const STORAGE_KEY_SERVERS = 'elk-servers'
export const STORAGE_KEY_NODES = 'elk-nodes'
export const STORAGE_KEY_CURRENT_USER = 'elk-current-user'
export const STORAGE_KEY_CURRENT_USER_HANDLE = 'elk-current-user-handle'
export const STORAGE_KEY_NOTIFY_TAB = 'elk-notify-tab'
export const STORAGE_KEY_FIRST_VISIT = 'elk-first-visit'

View file

@ -45,6 +45,7 @@ There are 5 environment variables to add.
| NUXT_CLOUDFLARE_NAMESPACE_ID | This is your CloudFlare KV Namespace ID. You can find it in "Workers > KV". |
| NUXT_STORAGE_DRIVER | Because we're using CloudFlare, we'll need to set this to `cloudflare`. |
| NUXT_PUBLIC_DEFAULT_SERVER | This is the address of the Mastodon instance that will show up when a user visits your Elk deployment and is not logged in. If you don't make that variable, it will point to `m.webtoo.ls` by default. |
| SINGLE_INSTANCE_SERVER | This can't be set at runtime, but if enabled at build-time it will disable signing in to servers other than the server specified in `NUXT_PUBLIC_DEFAULT_SERVER` |
| NUXT_PUBLIC_PRIVACY_POLICY_URL | This is the URL to a web page with information on your privacy policy. |
That's it! All that's left to do is...

View file

@ -9,7 +9,7 @@
"preview": "nuxi preview"
},
"devDependencies": {
"@nuxt-themes/docus": "^1.4.7",
"nuxt": "^3.1.0"
"@nuxt-themes/docus": "^1.6.1",
"nuxt": "^3.1.1"
}
}

View file

@ -15,16 +15,16 @@ const isGrayscale = usePreferences('grayscaleMode')
</script>
<template>
<div h-full :data-mode="isHydrated && isGrayscale ? 'grayscale' : ''">
<div h-full :data-mode="isHydrated && isGrayscale ? 'grayscale' : ''" data-tauri-drag-region>
<main flex w-full mxa lg:max-w-80rem class="native:grid native:sm:grid-cols-[auto_1fr] native:lg:grid-cols-[auto_minmax(600px,2fr)_1fr]">
<aside class="hidden native:w-auto sm:flex w-1/8 md:w-1/6 lg:w-1/5 xl:w-1/4 justify-end xl:me-4 zen-hide" relative>
<aside class="native:w-auto w-1/8 md:w-1/6 lg:w-1/5 xl:w-1/4 zen-hide" hidden sm:flex justify-end xl:me-4 native:me-0 relative>
<div sticky top-0 w-20 xl:w-100 h-screen flex="~ col" lt-xl-items-center>
<slot name="left">
<div flex="~ col" overflow-y-auto justify-between h-full max-w-full pt-5 native:pt-7 overflow-x-hidden>
<div flex="~ col" overflow-y-auto justify-between h-full max-w-full overflow-x-hidden>
<NavTitle />
<NavSide command />
<div flex-auto />
<div v-if="isHydrated" flex flex-col>
<div v-if="isHydrated" flex flex-col sticky bottom-0 bg-base>
<div hidden xl:block>
<UserSignInEntry v-if="!currentUser" />
</div>
@ -59,7 +59,7 @@ const isGrayscale = usePreferences('grayscaleMode')
<NavBottom v-if="isHydrated" sm:hidden />
</div>
</div>
<aside v-if="isHydrated && !wideLayout" class="hidden sm:none lg:block w-1/4 zen-hide">
<aside v-if="isHydrated && !wideLayout" class="hidden lg:w-1/5 xl:w-1/4 sm:none xl:block native:w-full zen-hide">
<div sticky top-0 h-screen flex="~ col" gap-2 py3 ms-2>
<slot name="right">
<div flex-auto />

View file

@ -28,6 +28,8 @@
"muted_users": "المستخدمون المكتومون",
"muting": "قُمت بكتم",
"mutuals": "المتبادلون",
"notifications_on_post_disable": "التوقف عن إشعاري عندما ينشر {username}",
"notifications_on_post_enable": "إشعاري عندما ينشر {username}",
"pinned": "المثبتة",
"posts": "المنشورات",
"posts_count": "{0} منشورات|{0} منشور|{0} منشورين|{0} منشورات|{0} منشور|{0} منشور",
@ -35,7 +37,9 @@
"profile_unavailable": "حساب غير متوفر",
"unblock": "إلغاء حظر",
"unfollow": "إلغاء متابعة",
"unmute": "إلغاء كتم"
"unmute": "إلغاء كتم",
"view_other_followers": "قد لا يتم عرض المتابعين من خوادم مختلفين عن الخاص بك.",
"view_other_following": "قد لا يتم عرض من تتابع من خوادم مختلفين عن الخاص بك."
},
"action": {
"apply": "تطبيق",
@ -44,6 +48,7 @@
"boost": "إعادة نشر",
"boost_count": "{0}",
"boosted": "أعيد نشرها",
"clear_publish_failed": "مسح أخطاء النشر",
"clear_upload_failed": "مسح أخطاء تحميل الملف",
"close": "أغلق",
"compose": "منشور جديد",
@ -66,7 +71,7 @@
"switch_account": "تغيير الحساب",
"vote": "تصويت"
},
"app_desc_short": "منصة تواصل Mastodon رشيقة",
"app_desc_short": "منصة تواصل ماستودون رشيقة",
"app_logo": "Elk شعار",
"app_name": "Elk",
"attachment": {
@ -101,9 +106,52 @@
"draft_title": "مسودة {0}",
"drafts": "المسودات ({v})"
},
"confirm": {
"block_account": {
"cancel": "إلغاء",
"confirm": "حظر",
"title": "هل أنت متأكد أنك تريد حظر {0}؟"
},
"block_domain": {
"cancel": "إلغاء",
"confirm": "حظر",
"title": "هل أنت متأكد أنك تريد حظر {0}؟"
},
"common": {
"cancel": "لا",
"confirm": "نعم"
},
"delete_posts": {
"cancel": "إلغاء",
"confirm": "حذف",
"title": "هل أنت متأكد أنك تريد حذف هذا المنشور؟"
},
"mute_account": {
"cancel": "إلغاء",
"confirm": "كتم",
"title": "هل أنت متأكد أنك تريد كتم {0}؟"
},
"show_reblogs": {
"cancel": "إلغاء",
"confirm": "اعرض",
"title": "هل أنت متأكد أنك تريد إظهار تعزيزات من {0}؟"
},
"unfollow": {
"cancel": "إلغاء",
"confirm": "إلغاء المتابعة",
"title": "هل أنت متأكد أنك تريد إلغاء المتابعة؟"
}
},
"conversation": {
"with": "مع"
},
"custom_cards": {
"stackblitz": {
"lines": "الخطوط {0}",
"open": "افتح",
"snippet_from": "مقتطف من {0}"
}
},
"error": {
"account_not_found": "حساب {0} غير موجود",
"explore-list-empty": "لا توجد مشاركات شائعة الآن. تحقق مرة أخرى لاحقًا!",
@ -113,6 +161,12 @@
"unsupported_file_format": "لا يمكن تحميل هذا النوع من الملفات"
},
"help": {
"build_preview": {
"desc1": "أنت تشاهد حاليًا إصدار معاينة من Elk عامة - {0}.",
"desc2": "قد يحتوي على تغييرات لم تتم مراجعتها أو حتى ضارة.",
"desc3": "لا تسجل الدخول بحسابك الحقيقي.",
"title": "معاينة النشر"
},
"desc_highlight": "توقع بعض الأخطاء والميزات المفقودة هنا وهناك.",
"desc_para1": "نشكرك على اهتمامك بتجربة Elk ، عميل ماستدون العام!",
"desc_para2": "نحن نعمل بجد على التطوير وتحسينه بمرور الوقت. وسندعوك قريبًا للانضمام إلى القوة بمجرد أن نجعلها مفتوحة المصدر قريبًا!",
@ -125,10 +179,16 @@
"language": {
"search": "بحث"
},
"list": {
"add_account": "إضافة حساب إلى القائمة",
"modify_account": "تعديل القوائم مع الحساب",
"remove_account": "إزالة الحساب من القائمة"
},
"menu": {
"block_account": "حظر {0}",
"block_domain": "حظر المجال {0}",
"copy_link_to_post": "انسخ الرابط إلى هذا المنشور",
"copy_original_link_to_post": "انسخ الرابط الأصلي لهذا المنشور",
"delete": "حذف",
"delete_and_redraft": "حذف وإعادة صياغة",
"delete_confirm": {
@ -165,14 +225,18 @@
"blocked_users": "المستخدمين المحظورين",
"bookmarks": "العلامات المرجعية",
"built_at": "Built {0}",
"compose": "تأليف",
"conversations": "المحادثات",
"explore": "استكشف",
"favourites": "المفضلة",
"federated": "الفديرالية",
"home": "الرئيسيّة",
"list": "قائمة",
"lists": "القوائم",
"local": "المحلي",
"muted_users": "المستخدمون المكتموصين",
"notifications": "التنبيهات",
"privacy": "خصوصية",
"profile": "الصفحة التعريفية",
"search": "البحث",
"select_feature_flags": "تبديل علامات الميزات",
@ -202,27 +266,29 @@
},
"pwa": {
"dismiss": "تجاهل",
"title": "يتوفر تحديث Elk الجديد",
"install": "تحميل",
"install_title": "تحميل Elk",
"title": "يتوفر تحديث Elk جديد",
"update": "تحديث",
"update_available_short": "تحديث Elk",
"webmanifest": {
"canary": {
"description": "نسخة ويب رشيقة ل Mastodon (النسخة الإنشائية)",
"description": "نسخة ويب رشيقة لماستودون (النسخة الإنشائية)",
"name": "Elk (النسخة الإنشائية)",
"short_name": "Elk (النسخة الإنشائية)"
},
"dev": {
"description": "نسخة ويب رشيقة ل Mastodon (النسخة التطويرية)",
"description": "نسخة ويب رشيقة لماستودون (النسخة التطويرية)",
"name": "Elk (النسخة التطويرية)",
"short_name": "Elk (النسخة التطويرية)"
},
"preview": {
"description": "نسخة ويب رشيقة ل Mastodon (معاينة)",
"description": "نسخة ويب رشيقة لماستودون (معاينة)",
"name": "Elk (معاينة)",
"short_name": "Elk (معاينة)"
},
"release": {
"description": "نسخة ويب رشيقة ل Mastodon",
"description": "نسخة ويب رشيقة لماستودون",
"name": "Elk",
"short_name": "Elk"
}
@ -241,10 +307,11 @@
"sponsors": "الرعاة",
"sponsors_body_1": "تم تمويل Elk من قبل الشركات والأفراد التاليين:",
"sponsors_body_2": "وكذا من قبل الشركات التالية:",
"sponsors_body_3": "إذا كنت تستمتع بإستخدام Elk، فنحن نشجعك على التبرع لدعم المشروع."
"sponsors_body_3": "إذا كنت تستمتع بإستخدام Elk، فنحن نشجعك على التبرع لدعم المشروع.",
"version": "الإصدار"
},
"account_settings": {
"description": "قم بتحرير إعدادات حسابك في موقع Mastodon الأصلي",
"description": "قم بتحرير إعدادات حسابك في موقع ماستودون الأصل",
"label": "إعدادت الحساب"
},
"feature_flags": {
@ -260,7 +327,8 @@
"font_size": "حجم الخط",
"label": "واجهه المستخدم",
"light_mode": "وضع الضوء",
"system_mode": "النظام"
"system_mode": "النظام",
"theme_color": "وضع المظهر"
},
"language": {
"display_language": "اللغة المعروضة",
@ -317,7 +385,19 @@
},
"notifications_settings": "التنبيهات",
"preferences": {
"label": "التفضيلات"
"enable_autoplay": "تفعيل التشغيل التلقائي",
"github_cards": "بطاقات GitHub",
"grayscale_mode": "مظهر رمادي",
"hide_account_hover_card": "إخفاء بطاقة الحساب عند المرور فوقها",
"hide_boost_count": "إخفاء عدد التعزيز",
"hide_favorite_count": "إخفاء التعداد المفضل",
"hide_follower_count": "إخفاء عدد المتابعين",
"hide_reply_count": "إخفاء عدد الردود",
"hide_translation": "إخفاء الترجمة",
"label": "التفضيلات",
"title": "الميزات التجريبية",
"user_picker": "منتقي الحسابات",
"virtual_scroll": "التمرير الافتراضي"
},
"profile": {
"appearance": {
@ -340,22 +420,20 @@
"export": "تصدير معلومات المستخدم",
"import": "استيراد معلومات المستخدم",
"label": "المستخدمون المسجلون"
},
"wellness": {
"feature": {
"hide_boost_count": "إخفاء عدد المشاركات",
"hide_favorite_count": "إخفاء عدد المفضلة",
"hide_follower_count": "إخفاء عدد المتابعين"
},
"label": "الصحة العامة"
}
},
"share-target": {
"description": "يمكن تحديث Elk بحيث يمكنك مشاركة المحتوى من التطبيقات الأخرى ، ما عليك سوى تثبيت Elk على جهازك أو الكمبيوتر وتسجيل الدخول.",
"hint": "لمشاركة المحتوى مع Elk ، يجب تثبيت Elk ويجب تسجيل الدخول.",
"title": "شارك مع Elk"
},
"state": {
"attachments_exceed_server_limit": "تجاوز عدد المرفقات الحد الأقصى لكل منشور.",
"attachments_limit_error": "تجاوز الحد لل منشور",
"edited": "(معدل)",
"editing": "تعديل",
"loading": "جاري التحميل ...",
"publish_failed": "فشل النشر",
"publishing": "قيد النشر",
"upload_failed": "التحميل فشل",
"uploading": "جاري التحميل ..."
@ -390,6 +468,7 @@
"edited": "تم تعديله في {0}"
},
"tab": {
"accounts": "الحسابات",
"for_you": "مصممة لك",
"hashtags": "هاشتاغ",
"media": "الصور/الفيديو",
@ -455,6 +534,7 @@
"explore_links_intro": "يتم التحدث عن هذه القصص الإخبارية من قبل الأشخاص الموجودين على هذه الشبكة وغيرها من الشبكات اللامركزية في الوقت الحالي",
"explore_posts_intro": "تكتسب هذه المنشورات الكثير من النشاط على الشبكة وغيرها من الشبكات اللامركزية في الوقت الحالي",
"explore_tags_intro": "تكتسب هذه الهاشتاغ الكثير من النشاط بين الأشخاص على هذه الشبكة وغيرها من الشبكات اللامركزية في الوقت الحالي",
"publish_failed": "أغلق الرسائل الفاشلة أعلى المحرر لإعادة نشر المنشورات",
"toggle_code_block": "تبديل كتلة التعليمات البرمجية"
},
"user": {

View file

@ -270,7 +270,14 @@
},
"language": {
"display_language": "Anzeigesprache",
"label": "Sprache"
"label": "Sprache",
"translations": {
"add": "Hinzufügen",
"choose_language": "Sprache wählen",
"heading": "Übersetzungen",
"hide_specific": "Bestimmte Übersetzungen ausblenden",
"remove": "Entfernen"
}
},
"notifications": {
"label": "Benachrichtigungen",
@ -328,7 +335,7 @@
"hide_boost_count": "Boost-Zähler ausblenden",
"hide_favorite_count": "Favoritenzahl ausblenden",
"hide_follower_count": "Anzahl der Follower ausblenden",
"hide_translation": "Übersetzungen ausblenden",
"hide_translation": "Übersetzungen komplett ausblenden",
"label": "Einstellungen",
"title": "Experimentelle Funktionen",
"user_picker": "Benutzerauswahl",

View file

@ -35,6 +35,7 @@
"posts_count": "{0} Posts|{0} Post|{0} Posts",
"profile_description": "{0}'s profile header",
"profile_unavailable": "Profile unavailable",
"request_follow": "Request to follow",
"unblock": "Unblock",
"unfollow": "Unfollow",
"unmute": "Unmute",
@ -68,6 +69,7 @@
"save": "Save",
"save_changes": "Save changes",
"sign_in": "Sign in",
"sign_in_to": "Sign in to {0}",
"switch_account": "Switch account",
"vote": "Vote"
},
@ -116,6 +118,11 @@
"cancel": "No",
"confirm": "Yes"
},
"delete_list": {
"cancel": "Cancel",
"confirm": "Delete",
"title": "Are you sure you want to delete the \"{0}\" list?"
},
"delete_posts": {
"cancel": "Cancel",
"confirm": "Delete",
@ -169,6 +176,7 @@
"desc_para4": "Elk is Open Source. If you'd like to help with testing, giving feedback, or contributing,",
"desc_para5": "reach out to us on GitHub",
"desc_para6": "and get involved.",
"footer_team": "The Elk Team",
"title": "Elk is in Preview!"
},
"language": {
@ -176,8 +184,19 @@
},
"list": {
"add_account": "Add account to list",
"cancel_edit": "Cancel editing",
"clear_error": "Clear error",
"create": "Create",
"delete": "Delete this list",
"delete_error": "There was an error while deleting the list",
"edit": "Edit this list",
"edit_error": "There was an error while updating the list",
"error": "There was an error while creating the list",
"error_prefix": "Error: ",
"list_title_placeholder": "List title",
"modify_account": "Modify lists with account",
"remove_account": "Remove account from list"
"remove_account": "Remove account from list",
"save": "Save changes"
},
"menu": {
"block_account": "Block {0}",
@ -290,6 +309,7 @@
},
"settings": {
"about": {
"built_at": "Built",
"label": "About",
"meet_the_team": "Meet the team",
"sponsor_action": "Sponsor us",
@ -316,7 +336,14 @@
},
"language": {
"display_language": "Display Language",
"label": "Language"
"label": "Language",
"translations": {
"add": "Add",
"choose_language": "Choose language",
"heading": "Translations",
"hide_specific": "Hide specific translations",
"remove": "Remove"
}
},
"notifications": {
"label": "Notifications",
@ -345,10 +372,13 @@
"save_settings": "Save settings",
"subscription_error": {
"clear_error": "Clear error",
"invalid_vapid_key": "The VAPID public key seems to be invalid.",
"permission_denied": "Permission denied: enable notifications in your browser.",
"repo_link": "Elk's repository in Github",
"request_error": "An error occurred while requesting the subscription, try again and if the error persists, please report the issue to the Elk repository.",
"title": "Could not subscribe to push notifications",
"too_many_registrations": "Due to browser limitations, Elk cannot use the push notifications service for multiple accounts on different servers. You should unsubscribe from push notifications on another account and try again."
"too_many_registrations": "Due to browser limitations, Elk cannot use the push notifications service for multiple accounts on different servers. You should unsubscribe from push notifications on another account and try again.",
"vapid_not_supported": "Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol."
},
"title": "Push notifications settings",
"undo_settings": "Undo changes",
@ -365,23 +395,29 @@
"re_auth": "It seems that your server does not support push notifications. Try sign out and sign in again, if this message still appears contact your server administrator."
}
},
"show_btn": "Go to notifications settings"
"show_btn": "Go to notifications settings",
"under_construction": "Under construction"
},
"notifications_settings": "Notifications",
"preferences": {
"enable_autoplay": "Enable Autoplay",
"enable_pinch_to_zoom": "Enable pinch to zoom",
"github_cards": "GitHub Cards",
"grayscale_mode": "Grayscale mode",
"hide_account_hover_card": "Hide account hover card",
"hide_alt_indi_on_posts": "Hide alt indicator on posts",
"hide_boost_count": "Hide boost count",
"hide_favorite_count": "Hide favorite count",
"hide_follower_count": "Hide follower count",
"hide_reply_count": "Hide reply count",
"hide_translation": "Hide translation",
"hide_username_emojis": "Hide username emojis",
"hide_username_emojis_description": "Hides emojis from usernames in timelines. Emojis will still be visible in their profiles.",
"label": "Preferences",
"title": "Experimental Features",
"user_picker": "User Picker",
"virtual_scroll": "Virtual Scrolling"
"virtual_scroll": "Virtual Scrolling",
"wellbeing": "Wellbeing"
},
"profile": {
"appearance": {
@ -430,8 +466,10 @@
"filter_removed_phrase": "Removed by filter",
"filter_show_anyway": "Show anyway",
"img_alt": {
"ALT": "ALT",
"desc": "Description",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"read": "Read {0} description"
},
"poll": {
"count": "{0} votes|{0} vote|{0} votes",
@ -519,8 +557,11 @@
"explore_links_intro": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
"explore_posts_intro": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
"explore_tags_intro": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
"open_editor_tools": "Editor tools",
"publish_failed": "Close failed messages at the top of editor to republish posts",
"toggle_code_block": "Toggle code block"
"toggle_bold": "Toggle bold",
"toggle_code_block": "Toggle code block",
"toggle_italic": "Toggle italic"
},
"user": {
"add_existing": "Add an existing account",

View file

@ -1 +1,191 @@
{}
{
"a11y": {
"locale_changed": "Idioma configurado en {0}",
"locale_changing": "Actualizando idioma, espera..."
},
"account": {
"avatar_description": "Foto de perfil de",
"blocked_by": "Estás bloqueado por este usuario.",
"blocked_domains": "Dominios ocultos",
"favourites": "Publicaciones Favoritas",
"go_to_profile": "Ver perfil",
"moved_title": "indicó que su nueva cuenta es ",
"mutuals": "Mutuales",
"notifications_on_post_disable": "No notificar cuando {username} publique",
"notifications_on_post_enable": "Notificarme cuando {username} publique",
"pinned": "Publicaciones ancladas",
"profile_description": "Imagen de portada de {0}",
"unmute": "Quitar silencio"
},
"action": {
"apply": "Guardar cambios",
"bookmark": "Marcar",
"confirm": "Cortar",
"edit": "Actualizar",
"enter_app": "Ingresar",
"favourite": "Marcar como favorita",
"favourited": "Marcada como favorita",
"reset": "Resetear",
"switch_account": "Cambiar de cuenta"
},
"app_logo": "Logo de Elk",
"attachment": {
"remove_label": "Eliminar archivo adjunto"
},
"command": {
"n-people-in-the-past-n-days": "{0} usuarios en los últimos {1} días"
},
"common": {
"end_of_list": "Fin de la lista",
"offline_desc": "No tienes acceso a internet. Por favor, comprueba que tienes una conexión a la red."
},
"confirm": {
"block_account": {
"cancel": "No",
"confirm": "Sí, bloquear",
"title": "¿De verdad quieres bloquear a {0}?"
},
"block_domain": {
"cancel": "No",
"confirm": "Sí ocultar",
"title": "¿De verdad quieres ocultar a {0}?"
},
"delete_posts": {
"title": "¿De verdad quieres eliminar esta publicación?"
},
"mute_account": {
"title": "¿De verdad quieres silenciar a {0}?"
},
"show_reblogs": {
"cancel": "No",
"confirm": "Sí, ver",
"title": "¿De verdad quieres ver los retoots de {0}"
},
"unfollow": {
"title": "¿De verdad quieres dejar de seguir?"
}
},
"error": {
"file_size_cannot_exceed_n_mb": "El tamaño del archivo no puede ser de más de {0}MB",
"unsupported_file_format": "Formato de archivo no soportado"
},
"help": {
"desc_highlight": "Es normal que aparezcan algunos errores y funcionalidades que aún estén en desarrollo.",
"desc_para1": "¡Gracias por tu interés en probar Elk, nuestro cliente genérico en desarrollo para Mastodon!",
"desc_para2": "Estamos haciendo lo posible para ir mejorando constantemente.",
"desc_para4": "Elk es de código abierto. Si quieres probar para ayudar, opinar o contribuir,",
"desc_para5": "contáctanos a través de GitHub"
},
"list": {
"add_account": "Añadir cuenta a la lista",
"remove_account": "Quitar cuenta de la lista"
},
"menu": {
"block_domain": "Ocultar dominio {0}",
"delete_and_redraft": "Eliminar y volver a borrador",
"edit": "Actualizar",
"pin_on_profile": "Anclar en tu perfil",
"show_favourited_and_boosted_by": "Ver quien marcó como favorita y quien retooteó",
"show_reblogs": "Ver retoots de {0}",
"unblock_domain": "Ver dominio {0}",
"unmute_account": "Quitar silencio a {0}",
"unmute_conversation": "Quitar silencio de la publicación",
"unpin_on_profile": "Desanclar del perfil"
},
"nav": {
"back": "Atrás",
"blocked_domains": "Dominios ocultos",
"built_at": "Generado {0}",
"conversations": "Mensajes directos",
"favourites": "Favoritas",
"federated": "Historia federada",
"local": "Historia local",
"settings": "Preferencias",
"toggle_theme": "Cambiar tema de color",
"zen_mode": "Modo sin distracciones"
},
"notification": {
"followed_you": "te siguió",
"update_status": "actualizó su publicación"
},
"placeholder": {
"default_1": "¿En qué piensas?"
},
"search": {
"search_empty": "No se encontraron resultados para la búsqueda"
},
"settings": {
"about": {
"built_at": "Compilado el",
"sponsor_action": "Patrocina"
},
"account_settings": {
"description": "Actualiza los ajustes de tu cuenta en la interfaz de Mastodon.",
"label": "Configuración de cuenta"
},
"interface": {
"color_mode": "Temas de color",
"dark_mode": "Tema oscuro",
"default": " (predeterminado)",
"font_size": "Tamaño de fuente",
"light_mode": "Tema claro",
"system_mode": "Color del sistema"
},
"language": {
"display_language": "Idioma en pantalla",
"translations": {
"add": "Añadir",
"hide_specific": "Ocultar una traducción específica",
"remove": "Quitar"
}
},
"notifications": {
"notifications": {
"label": "Preferencias de notificaciones"
},
"push_notifications": {
"label": "Preferencias de notificaciones push"
},
"show_btn": "Ir a preferencias de notificaciones",
"under_construction": "En desarrollo"
},
"preferences": {
"grayscale_mode": "Tema en escala de grises"
},
"profile": {
"appearance": {
"description": "Actualizar foto, nombre de usuario, perfil, etc.",
"display_name": "Nombre visible",
"profile_metadata_desc": "Puedes ver en tu perfil hasta 4 elementos en forma de tabla",
"title": "Actualizar perfil"
},
"featured_tags": {
"description": "Los usuarios navegan por tus publicaciones públicas con estas etiquetas.",
"label": "Etiquetas destacadas"
}
},
"users": {
"label": "Usuarios en línea"
}
},
"status": {
"spoiler_show_less": "Menos"
},
"tab": {
"hashtags": "Etiquetas"
},
"timeline": {
"show_new_items": "Ver {v} nuevas publicaciones|Ver {v} nueva publicación|Ver {v} nuevas publicaciones"
},
"title": {
"federated_timeline": "Historia federada",
"local_timeline": "Historia local"
},
"tooltip": {
"add_emojis": "Insertar emoji",
"change_content_visibility": "Cambiar visibilidad"
},
"user": {
"add_existing": "Añadir una cuenta existente"
}
}

View file

@ -8,7 +8,7 @@
},
"account": {
"avatar_description": "avatar de {0}",
"blocked_by": "Estás bloqueado por este usuario.",
"blocked_by": "Has sido bloqueado por este usuario.",
"blocked_domains": "Dominios bloqueados",
"blocked_users": "Usuarios bloqueados",
"blocking": "Bloqueado",
@ -35,6 +35,7 @@
"posts_count": "{0} Publicaciones|{0} Publicación|{0} Publicaciones",
"profile_description": "Encabezado del perfil de {0}",
"profile_unavailable": "Perfil no disponible",
"request_follow": "Solicitud para seguirte",
"unblock": "Desbloquear",
"unfollow": "Dejar de seguir",
"unmute": "Dejar de silenciar",
@ -68,6 +69,7 @@
"save": "Guardar",
"save_changes": "Guardar cambios",
"sign_in": "Iniciar sesión",
"sign_in_to": "Iniciar sesión en {0}",
"switch_account": "Cambiar cuenta",
"vote": "Votar"
},
@ -76,7 +78,7 @@
"app_name": "Elk",
"attachment": {
"edit_title": "Descripción",
"remove_label": "Eliminar archivo adjunto"
"remove_label": "Eliminar fichero adjunto"
},
"command": {
"activate": "Activar",
@ -116,6 +118,11 @@
"cancel": "No",
"confirm": "Si"
},
"delete_list": {
"cancel": "Cancelar",
"confirm": "Eliminar",
"title": "¿Está seguro de querer eliminar la lista \"{0}\"?"
},
"delete_posts": {
"cancel": "Cancelar",
"confirm": "Eliminar",
@ -150,10 +157,10 @@
"error": {
"account_not_found": "No se encontró la cuenta {0}",
"explore-list-empty": "No hay tendencias en este momento. ¡Vuelve más tarde!",
"file_size_cannot_exceed_n_mb": "El tamaño del archivo no puede exceder los {0}MB",
"file_size_cannot_exceed_n_mb": "El tamaño del fichero no puede exceder los {0}MB",
"sign_in_error": "No se pudo conectar con el servidor.",
"status_not_found": "Estado no encontrado",
"unsupported_file_format": "Tipo de archivo no soportado"
"status_not_found": "Publicación no encontrada",
"unsupported_file_format": "Tipo de fichero no soportado"
},
"help": {
"build_preview": {
@ -164,11 +171,12 @@
},
"desc_highlight": "Es normal encontrar algunos errores y características faltantes aquí y allá.",
"desc_para1": "¡Gracias por el interés en probar Elk, nuestro cliente genérico en desarrollo para Mastodon!",
"desc_para2": "Estamos trabajando duro en el desarrollo y mejorándolo constantemente. ¡Y pronto te invitaremos a que te unas una vez que lo hagamos de código abierto!",
"desc_para3": "Para ayudar a impulsar el desarrollo, puedes patrocinar a los miembros de nuestro equipo con los enlaces a continuación.",
"desc_para4": "Antes de eso, si te gustaría ayudar probando, dando opinión o contribuyendo,",
"desc_para2": "Estamos trabajando duro en el desarrollo y mejorándolo constantemente.",
"desc_para3": "Para ayudar a impulsar el desarrollo, puedes patrocinar a los miembros de nuestro equipo con los enlaces a continuación. ¡Esperamos que estés disfrutando Elk!",
"desc_para4": "Elk es de código abierto, si te gustaría ayudar probando, dando opinión o contribuyendo,",
"desc_para5": "ponte en contacto con nosotros a través de GitHub",
"desc_para6": "para participar.",
"footer_team": "El equipo de desarrollo de Elk",
"title": "¡Elk está en Vista Previa!"
},
"language": {
@ -176,8 +184,19 @@
},
"list": {
"add_account": "Agregar cuenta a la lista",
"cancel_edit": "Cancelar edición",
"clear_error": "Limpiar error",
"create": "Crear",
"delete": "Eliminar esta lista",
"delete_error": "Se produjo un error eliminando la lista",
"edit": "Ediar esta lista",
"edit_error": "Se produjo un error modificando la lista",
"error": "Se produjo un error creando la lista",
"error_prefix": "Error: ",
"list_title_placeholder": "Título de la lista",
"modify_account": "Modificar listas con cuenta",
"remove_account": "Eliminar cuenta de la lista"
"remove_account": "Eliminar cuenta de la lista",
"save": "Guardar"
},
"menu": {
"block_account": "Bloquear a {0}",
@ -245,7 +264,7 @@
"reblogged_post": "retooteó tu publicación",
"request_to_follow": "ha solicitado seguirte",
"signed_up": "registrado",
"update_status": "ha actualizado su estado"
"update_status": "ha actualizado su publicación"
},
"placeholder": {
"content_warning": "Escribe tu advertencia aquí",
@ -290,6 +309,7 @@
},
"settings": {
"about": {
"built_at": "Fecha de compilación",
"label": "Acerca de",
"meet_the_team": "Conoce al equipo",
"sponsor_action": "Patrocinar",
@ -301,14 +321,14 @@
"version": "Versión"
},
"account_settings": {
"description": "Edita los ajustes de tu cuenta en la interfaz de Mastodon",
"description": "Edita los ajustes de tu cuenta en la interfaz de Mastodon.",
"label": "Ajustes de cuenta"
},
"interface": {
"color_mode": "Modos de color",
"dark_mode": "Modo oscuro",
"default": " (por defecto)",
"font_size": "Tamaño de Letra",
"font_size": "Tamaño de letra",
"label": "Interfaz",
"light_mode": "Modo claro",
"system_mode": "Sistema",
@ -316,7 +336,14 @@
},
"language": {
"display_language": "Idioma de pantalla",
"label": "Idioma"
"label": "Idioma",
"translations": {
"add": "Agregar",
"choose_language": "Seleccionar idioma",
"heading": "Traducciones",
"hide_specific": "Ocultar una traducción en específico",
"remove": "Eliminar"
}
},
"notifications": {
"label": "Notificaciones",
@ -332,7 +359,7 @@
"reblog": "Retooteo de tus publicaciones",
"title": "¿Qué notificaciones recibir?"
},
"description": "Reciba notificaciones incluso cuando no estés utilizando Elk.",
"description": "Recibe notificaciones incluso cuando no estés utilizando Elk.",
"instructions": "¡No olvides guardar los cambios utilizando el botón @:settings.notifications.push_notifications.save_settings{'!'}",
"label": "Ajustes de notificaciones push",
"policy": {
@ -345,10 +372,13 @@
"save_settings": "Guardar cambios",
"subscription_error": {
"clear_error": "Limpiar error",
"invalid_vapid_key": "La clave pública VAPID parece no ser válida.",
"permission_denied": "Permiso denegado: habilita las notificaciones en tu navegador.",
"repo_link": "Repositorio de Elk en Github",
"request_error": "Se produjo un error al solicitar la suscripción, inténtalo de nuevo y si el error persiste, notifica la incidencia en el repositorio de Elk.",
"title": "No se pudo suscribir a las notificaciones push",
"too_many_registrations": "Debido a las limitaciones del navegador, Elk no puede habilitar las notificaciones push para múltiples cuentas en diferentes servidores. Deberá cancelar las subscripciones a notificaciones push en las otras cuentas e intentarlo de nuevo."
"too_many_registrations": "Debido a las limitaciones del navegador, Elk no puede habilitar las notificaciones push para múltiples cuentas en diferentes servidores. Deberá cancelar las subscripciones a notificaciones push en las otras cuentas e intentarlo de nuevo.",
"vapid_not_supported": "Su navegador es compatible con las notificaciones web push, pero no parece implementar el protocolo VAPID."
},
"title": "Ajustes de notificaciones push",
"undo_settings": "Deshacer cambios",
@ -365,19 +395,22 @@
"re_auth": "Parece que tu servidor no soporta notificaciones push. Prueba a cerrar la sesión y volver a iniciarla, si este mensaje sigue apareciendo contacta con el administrador de tu servidor."
}
},
"show_btn": "Ir a ajustes de notificaciones"
"show_btn": "Ir a ajustes de notificaciones",
"under_construction": "En construcción"
},
"notifications_settings": "Notificaciones",
"preferences": {
"enable_autoplay": "Habilitar auto-reproducción",
"enable_autoplay": "Habilitar reproducción automática",
"enable_pinch_to_zoom": "Habilitar pellizcar para hacer zoom",
"github_cards": "Tarjetas GitHub",
"grayscale_mode": "Modo escala de grises",
"hide_account_hover_card": "Ocultar tarjeta flotante de cuenta",
"hide_boost_count": "Ocultar contador de retoots",
"hide_favorite_count": "Ocultar contador de favoritas",
"hide_follower_count": "Ocultar contador de seguidores",
"hide_reply_count": "Ocultar contador de respuestas",
"hide_favorite_count": "Ocultar número de publicaciones favoritas",
"hide_follower_count": "Ocultar número de seguidores",
"hide_reply_count": "Ocultar número de respuestas",
"hide_translation": "Ocultar traducción",
"hide_username_emojis": "Ocultar emojis en el nombre de usuario",
"label": "Preferencias",
"title": "Funcionalidades experimentales",
"user_picker": "Selector de usuarios",
@ -395,7 +428,7 @@
},
"featured_tags": {
"description": "Las personas pueden navegar por tus publicaciones públicas con estas etiquetas.",
"label": "Etiquetas destacados"
"label": "Etiquetas destacadas"
},
"label": "Perfil"
},
@ -510,7 +543,7 @@
},
"tooltip": {
"add_content_warning": "Añadir advertencia de contenido",
"add_emojis": "Añadir emojis",
"add_emojis": "Agregar emojis",
"add_media": "Añadir imágenes, video o audio",
"add_publishable_content": "Publicar contenido",
"change_content_visibility": "Cambiar visibilidad de contenido",
@ -519,12 +552,15 @@
"explore_links_intro": "Estas noticias están siendo comentadas ahora mismo por los usuarios de este y otros servidores de la red descentralizada.",
"explore_posts_intro": "Estos mensajes de este y otros servidores de la red descentralizada están siendo tendencia ahora mismo en este servidor.",
"explore_tags_intro": "Estas etiquetas están siendo tendencia ahora mismo entre los usuarios de este y otros servidores de la red descentralizada.",
"open_editor_tools": "Herramientas de edición",
"publish_failed": "Cierra los mensajes fallidos en la parte superior del editor para volver a publicar",
"toggle_code_block": "Cambiar a bloque de código"
"toggle_bold": "Cambiar a negrita",
"toggle_code_block": "Cambiar a bloque de código",
"toggle_italic": "Cambiar a cursiva"
},
"user": {
"add_existing": "Agregar una cuenta existente",
"server_address_label": "Dirección de Servidor de Mastodon",
"server_address_label": "Dirección de servidor de Mastodon",
"sign_in_desc": "Inicia sesión para seguir perfiles o etiquetas, marcar cómo favorita, compartir y responder a publicaciones, o interactuar con un servidor diferente con tu usuario.",
"sign_in_notice_title": "Viendo información pública de {0}",
"sign_out_account": "Cerrar sesión {0}",

View file

@ -8,9 +8,9 @@
},
"account": {
"avatar_description": "Avatar de {0}",
"blocked_by": "Vous êtes bloqué·e par cet·te utilisateur·ice.",
"blocked_by": "Ce compte vous a bloqué",
"blocked_domains": "Domaines bloqués",
"blocked_users": "Utilisateur·ice·s bloqué·e·s",
"blocked_users": "Comptes bloqués",
"blocking": "Bloqué·e",
"bot": "Automatisé",
"favourites": "Aimés",
@ -21,11 +21,11 @@
"followers_count": "{0} abonné·e|{0} abonné·e|{0} abonné·e·s",
"following": "Suivi·e",
"following_count": "{0} abonnements",
"follows_you": "Vous suit",
"follows_you": "@:account.follow_back",
"go_to_profile": "Aller à son profil",
"joined": "a rejoint",
"moved_title": "a indiqué que son nouveau compte est désormais :",
"muted_users": "Utilisateur·ice·s masqué·e·s",
"muted_users": "Comptes masqués",
"muting": "Masqué·e",
"mutuals": "Abonné·e·s",
"notifications_on_post_disable": "Arrêtez de me notifier lorsque {username} publie",
@ -39,8 +39,8 @@
"unblock": "Débloquer",
"unfollow": "Ne plus suivre",
"unmute": "Réafficher",
"view_other_followers": "Les abonné·e·s d'autres instances peuvent ne pas être affiché·e·s.",
"view_other_following": "Les suivis d'autres instances peuvent ne pas être affichés."
"view_other_followers": "Les comptes abonnés d'autres instances peuvent ne pas être affichés.",
"view_other_following": "Les comptes suivis d'autres instances peuvent ne pas être affichés."
},
"action": {
"apply": "Appliquer",
@ -118,10 +118,15 @@
"confirm": "Oui",
"title": "Êtes-vous sûr·e ?"
},
"delete_list": {
"cancel": "Annuler",
"confirm": "Supprimer",
"title": "Voulez-vous vraiment supprimer la liste \"{0}\" ?"
},
"delete_posts": {
"cancel": "Annuler",
"confirm": "Supprimer",
"title": "Certain·e de vouloir supprimer ce message ?"
"title": "Voulez-vous vraiment supprimer ce message ?"
},
"mute_account": {
"cancel": "Annuler",
@ -171,11 +176,28 @@
"desc_para4": "Avant cela, si vous voulez aider à tester, donner des retours ou contribuer",
"desc_para5": "contactez nous sur GitHub",
"desc_para6": "et rejoignez l'aventure.",
"footer_team": "L'équipe Elk",
"title": "Elk est en mode Aperçu !"
},
"language": {
"search": "Recherche"
},
"list": {
"add_account": "Ajouter le compte à la liste",
"cancel_edit": "Annuler l'édition",
"clear_error": "Effacer l'erreur",
"create": "Créer",
"delete": "Supprimer cette liste",
"delete_error": "Il y a eu une erreur lors de la suppression de la liste",
"edit": "Editer cette liste",
"edit_error": "Il y a eu une erreur lors de la mise à jour de la liste",
"error": "Il y a eu une erreur lors de la création de la liste",
"error_prefix": "Erreur :",
"list_title_placeholder": "Nom de la liste",
"modify_account": "Modifier les listes de ce compte",
"remove_account": "Supprimer ce compte de listes",
"save": "Enregistrer les changements"
},
"menu": {
"block_account": "Bloquer {0}",
"block_domain": "Bloquer le domaine {0}",
@ -212,7 +234,7 @@
"nav": {
"back": "Retourner à la page précédente",
"blocked_domains": "Domaines bloqués",
"blocked_users": "Utilisateur·ice·s bloqué·e·s",
"blocked_users": "Comptes bloqués",
"bookmarks": "Marque-pages",
"built_at": "Dernière compilation {0}",
"compose": "Composer",
@ -221,9 +243,12 @@
"favourites": "Favoris",
"federated": "Fédérés",
"home": "Accueil",
"list": "Liste",
"lists": "Listes",
"local": "Local",
"muted_users": "Utilisateur·ice·s masqué·e·s",
"muted_users": "Comptes masqués",
"notifications": "Notifications",
"privacy": "Données privées",
"profile": "Profil",
"search": "Rechercher",
"select_feature_flags": "Activer/Désactiver Feature Flags",
@ -287,11 +312,12 @@
},
"settings": {
"about": {
"built_at": "Dernière compilation",
"label": "À propos",
"meet_the_team": "Rencontrez l'équipe",
"sponsor_action": "Soutenez-nous",
"sponsor_action_desc": "Pour financer l'équipe développant Elk",
"sponsors": "Donateur·ice·s",
"sponsors": "Soutiens financiers",
"sponsors_body_1": "Elk existe grâce aux généreux soutien de :",
"sponsors_body_2": "Et toutes les personnes et sociétés soutenant l'équipe Elk et ses membres.",
"sponsors_body_3": "Si vous appréciez l'application, envisagez de nous soutenir :",
@ -313,7 +339,14 @@
},
"language": {
"display_language": "Langue d'affichage",
"label": "Langue"
"label": "Langue",
"translations": {
"add": "Ajouter",
"choose_language": "Choisir une langue",
"heading": "Traductions",
"hide_specific": "Cacher certaines traductions",
"remove": "Retirer"
}
},
"notifications": {
"label": "Notifications",
@ -323,7 +356,7 @@
"push_notifications": {
"alerts": {
"favourite": "Messages aimés",
"follow": "Nouveaux abonné·e·s",
"follow": "Nouveaux abonnés",
"mention": "Mentions",
"poll": "Sondages",
"reblog": "Messages partagés",
@ -362,16 +395,21 @@
"re_auth": "Il semble que votre serveur ne supporte pas les notifications push. \nEssayez de vous déconnecter et de vous reconnecter, si ce message persiste, contactez l'administrateur de votre serveur."
}
},
"show_btn": "Se rendre aux paramètres des notifications"
"show_btn": "Se rendre aux paramètres des notifications",
"under_construction": "En construction"
},
"notifications_settings": "Notifications",
"preferences": {
"enable_autoplay": "Activer la lecture automatique",
"github_cards": "GitHub Cards",
"enable_pinch_to_zoom": "Activer le zoom par pincement",
"github_cards": "Cartes GitHub",
"grayscale_mode": "Mode niveaux de gris",
"hide_account_hover_card": "Masquer la carte de survol du compte",
"hide_boost_count": "Cacher les compteurs de partages",
"hide_favorite_count": "Cacher les compteurs de favoris",
"hide_follower_count": "Cacher les compteurs d'abonné·e·s",
"hide_reply_count": "Cacher les compteurs de réponses",
"hide_translation": "Cacher traduction",
"label": "Préférences",
"title": "Fonctionnalités expérimentales",
"user_picker": "User Picker",
@ -380,7 +418,7 @@
"profile": {
"appearance": {
"bio": "Bio",
"description": "Éditer l'avatar, nom d'utilisateur·ice, profil, etc.",
"description": "Éditer l'avatar, nom du compte, profil, etc.",
"display_name": "Nom d'affichage",
"label": "Apparence",
"profile_metadata": "Métadonnées de profil",
@ -395,9 +433,9 @@
},
"select_a_settings": "Sélectionner un paramètre",
"users": {
"export": "Exporter les tokens d'utilisateur·ice",
"import": "Importer des tokens d'utilisateur·ice",
"label": "Utilisateur·ice·s connecté·e·s"
"export": "Exporter les tokens de compte",
"import": "Importer des tokens de compte",
"label": "Comptes connectés"
}
},
"share-target": {
@ -421,10 +459,13 @@
"edited": "Edité {0}",
"favourited_by": "Aimé par",
"filter_hidden_phrase": "Filtré par",
"filter_removed_phrase": "Supprimé par le filtre",
"filter_show_anyway": "Montrer coûte que coûte",
"img_alt": {
"ALT": "ALT",
"desc": "Description",
"dismiss": "Fermer"
"dismiss": "Fermer",
"read": "Lire la description de {0}"
},
"poll": {
"count": "{0} votes",
@ -445,8 +486,10 @@
"edited": "a édité {0}"
},
"tab": {
"accounts": "Comptes",
"for_you": "Pour vous",
"hashtags": "Hashtags",
"list": "Liste",
"media": "Média",
"news": "Actualités",
"notifications_all": "Tout",

View file

@ -116,6 +116,11 @@
"cancel": "いいえ",
"confirm": "はい"
},
"delete_list": {
"cancel": "キャンセル",
"confirm": "削除",
"title": "リスト \"{0}\" を本当に削除したいですか?"
},
"delete_posts": {
"cancel": "キャンセル",
"confirm": "削除",
@ -169,6 +174,7 @@
"desc_para4": "Elkはオープンソースです。テスト、フィードバックの提供、コントリビューションで開発を助けたい場合は",
"desc_para5": "GitHubで連絡をとり",
"desc_para6": "参加してください。",
"footer_team": "Elk チーム",
"title": "Elkはプレビュー版です"
},
"language": {
@ -176,8 +182,14 @@
},
"list": {
"add_account": "アカウントをリストに追加",
"cancel_edit": "編集をキャンセル",
"create": "作成",
"delete": "リストを削除",
"edit": "リストを編集",
"list_title_placeholder": "リストのタイトル",
"modify_account": "このアカウントでリストを編集",
"remove_account": "アカウントをリストから削除"
"remove_account": "アカウントをリストから削除",
"save": "変更を保存"
},
"menu": {
"block_account": "{0}さんをブロック",
@ -290,6 +302,7 @@
},
"settings": {
"about": {
"built_at": "ビルド",
"label": "Elkについて",
"meet_the_team": "チーム紹介",
"sponsor_action": "サポートしてください",
@ -316,7 +329,14 @@
},
"language": {
"display_language": "表示言語",
"label": "言語"
"label": "言語",
"translations": {
"add": "追加",
"choose_language": "言語を選択",
"heading": "翻訳",
"hide_specific": "特定の翻訳を隠す",
"remove": "削除"
}
},
"notifications": {
"label": "通知",
@ -365,11 +385,13 @@
"re_auth": "あなたのサーバーはプッシュ通知をサポートしていないようです。もしサインアウトとサインインをやり直してもこのメッセージがまだ表示される場合は、サーバー管理者に連絡してください。"
}
},
"show_btn": "通知設定に移動"
"show_btn": "通知設定に移動",
"under_construction": "工事中"
},
"notifications_settings": "通知",
"preferences": {
"enable_autoplay": "自動再生を有効化",
"enable_pinch_to_zoom": "ピンチによるズームを有効にする",
"github_cards": "GitHubカード",
"grayscale_mode": "グレースケールモード",
"hide_account_hover_card": "アカウントのホバーカードを隠す",
@ -430,8 +452,10 @@
"filter_removed_phrase": "フィルターにより削除",
"filter_show_anyway": "とにかく表示",
"img_alt": {
"ALT": "ALT",
"desc": "説明文",
"dismiss": "閉じる"
"dismiss": "閉じる",
"read": "{0} の説明文を読む"
},
"poll": {
"count": "{0} 票",

View file

@ -252,7 +252,7 @@
"install": "Zainstaluj",
"install_title": "Instalacja Elk",
"title": "Dostępna nowa aktualizacja Elk!",
"update": "Aktualizacja",
"update": "Aktualizuj",
"update_available_short": "Zaktualizuj Elk",
"webmanifest": {
"canary": {
@ -309,7 +309,14 @@
},
"language": {
"display_language": "Język aplikacji",
"label": "Język"
"label": "Język",
"translations": {
"add": "Dodaj",
"choose_language": "Wybierz język",
"heading": "Tłumaczenia",
"hide_specific": "Ukryj określone tłumaczenia",
"remove": "Usuń"
}
},
"notifications": {
"label": "Powiadomienia",
@ -380,7 +387,7 @@
"appearance": {
"bio": "Biogram",
"description": "Edytuj awatar, nazwę użytkownika, profil itp.",
"display_name": "Wyświetlana nazwa",
"display_name": "Widoczna nazwa",
"label": "Wygląd",
"profile_metadata": "Metadane profilu",
"profile_metadata_desc": "Możesz mieć maksymalnie {0} elementy wyświetlane jako tabela w swoim profilu",
@ -424,7 +431,7 @@
"filter_show_anyway": "Pokaż mimo wszystko",
"img_alt": {
"desc": "Opis",
"dismiss": "Odrzuć"
"dismiss": "Zamknij"
},
"poll": {
"count": "{0} głosów|{0} głos|{0} głosy|{0} głosów",

View file

@ -116,6 +116,11 @@
"cancel": "Não",
"confirm": "Sim"
},
"delete_list": {
"cancel": "Cancelar",
"confirm": "Eliminar",
"title": "Tem a certeza que pretende elimnar a lista \"{0}\"?"
},
"delete_posts": {
"cancel": "Cancelar",
"confirm": "Eliminar",
@ -169,6 +174,7 @@
"desc_para4": "Elk é um software de código aberto. Se quiser ajudar a testar a aplicação, dando o seu feedback ou contributo,",
"desc_para5": "pode encontrar-nos no GitHub",
"desc_para6": "e participar.",
"footer_team": "A Equipa do Elk",
"title": "Elk está em Antevisão!"
},
"language": {
@ -176,8 +182,19 @@
},
"list": {
"add_account": "Adicionar conta à lista",
"cancel_edit": "Cancelar edição",
"clear_error": "Limpar erro",
"create": "Criar",
"delete": "Eliminar esta lista",
"delete_error": "Ocorreu um erro ao eliminar a lista",
"edit": "Editar esta lista",
"edit_error": "Ocorreu um erro ao atualizar a lista",
"error": "Ocorreu um erro ao criar a lista",
"error_prefix": "Erro: ",
"list_title_placeholder": "Título da lista",
"modify_account": "Modificar listas com a conta",
"remove_account": "Remover conta da lista"
"remove_account": "Remover conta da lista",
"save": "Salvar alterações"
},
"menu": {
"block_account": "Bloquear {0}",
@ -290,6 +307,7 @@
},
"settings": {
"about": {
"built_at": "Produzido",
"label": "Sobre",
"meet_the_team": "Conheça a equipa",
"sponsor_action": "Patrocine-nos",
@ -316,7 +334,14 @@
},
"language": {
"display_language": "Idioma de Apresentação",
"label": "Idioma"
"label": "Idioma",
"translations": {
"add": "Adicionar",
"choose_language": "Selecionar idioma",
"heading": "Traduções",
"hide_specific": "Esconder para idiomas específicos",
"remove": "Remover"
}
},
"notifications": {
"label": "Notificações",
@ -345,10 +370,13 @@
"save_settings": "Salvar configurações",
"subscription_error": {
"clear_error": "Limpar erro",
"invalid_vapid_key": "A chave pública VAPID parece ser inválida.",
"permission_denied": "Permissão negada: habilite as notificações no seu browser.",
"repo_link": "Repositório do Elk no GitHub",
"request_error": "Um erro ocorreu durante o pedido de subscrição, tente novamente e se o erro persistir, por favor reporte o problema no repositório do Elk.",
"title": "Não é possível subscrever as notificações push",
"too_many_registrations": "Devido a limitações do browser, o Elk não consegue utilizar o serviço de notificações push para múltiplas contas em diferentes servidores. Deve cancelar a subscrição de notificações push nas outras contas e tentar novamente."
"too_many_registrations": "Devido a limitações do browser, o Elk não consegue utilizar o serviço de notificações push para múltiplas contas em diferentes servidores. Deve cancelar a subscrição de notificações push nas outras contas e tentar novamente.",
"vapid_not_supported": "O seu browser suporta Notificações Web Push, mas não parece ter implementado o protocolo VAPID."
},
"title": "Configuração de notificações push",
"undo_settings": "Reverter alterações",
@ -365,23 +393,28 @@
"re_auth": "Parece que o seu servidor não suporta notificações push. Tenta desconectar e voltar a entrar, se esta mensagem permanecer contacte o administrador do seu servidor."
}
},
"show_btn": "Ir para a configuração de notificações"
"show_btn": "Ir para a configuração de notificações",
"under_construction": "Em construção"
},
"notifications_settings": "Notificações",
"preferences": {
"enable_autoplay": "Habilitar Reprodução Automática",
"enable_pinch_to_zoom": "Habilitar afastar/aproximar dedos para fazer zoom",
"github_cards": "Cartões do GitHub",
"grayscale_mode": "Modo tons de cinza",
"hide_account_hover_card": "Esconder cartão flutuante de conta",
"hide_alt_indi_on_posts": "Esconder indicador alt nas publicações",
"hide_boost_count": "Esconder contagem de partilhas",
"hide_favorite_count": "Esconder contagem de favoritos",
"hide_follower_count": "Esconder contagem de seguidores",
"hide_reply_count": "Esconder contagem de respostas",
"hide_translation": "Esconder botão de tradução",
"hide_username_emojis": "Esconder emojis no nome de utilizador",
"label": "Preferências",
"title": "Funcionalidades Experimentais",
"user_picker": "Selecionador de Utilizador",
"virtual_scroll": "Deslocamento Virtual"
"virtual_scroll": "Deslocamento Virtual",
"wellbeing": "Bem-estar"
},
"profile": {
"appearance": {
@ -430,8 +463,10 @@
"filter_removed_phrase": "Removida pelo filtro",
"filter_show_anyway": "Mostrar mesmo assim",
"img_alt": {
"ALT": "ALT",
"desc": "Descrição",
"dismiss": "Dispensar"
"dismiss": "Dispensar",
"read": "Ler descrição de {0}"
},
"poll": {
"count": "{0} votos|{0} voto|{0} votos",

View file

@ -222,7 +222,7 @@
"settings": "Настройки",
"show_intro": "Показать интро",
"toggle_theme": "Переключить тему",
"zen_mode": "Рижим дзен"
"zen_mode": "Режим дзен"
},
"notification": {
"favourited_post": "добавили ваш пост в избранное",
@ -401,7 +401,7 @@
"state": {
"attachments_exceed_server_limit": "Количество вложенных файлов превысило лимит на одно сообщение.",
"attachments_limit_error": "Превышен лимит вложенных файлов на одно сообщение",
"edited": "(Отредактированно)",
"edited": "(Отредактировано)",
"editing": "Редактирование",
"loading": "Погрузка...",
"publish_failed": "Ошибка публикации",
@ -435,7 +435,7 @@
"try_original_site": "Посмотреть на оригинальном сайте"
},
"status_history": {
"created": "созданно {0}",
"created": "создано {0}",
"edited": "отредактировано {0}"
},
"tab": {

View file

@ -169,11 +169,17 @@
"desc_para4": "鹿鸣是开源的,如果你愿意帮助测试、提供反馈或作出贡献,",
"desc_para5": "在 GitHub 上联系我们",
"desc_para6": "来参与其中。",
"footer_team": "鹿鸣开发团队",
"title": "预览鹿鸣!"
},
"language": {
"search": "搜索"
},
"list": {
"add_account": "向列表中添加用户",
"modify_account": "修改列表中的用户",
"remove_account": "移除列表中的用户"
},
"menu": {
"block_account": "拉黑 {0}",
"block_domain": "拉黑域名 {0}",
@ -216,9 +222,12 @@
"favourites": "喜欢",
"federated": "跨站",
"home": "主页",
"list": "列表",
"lists": "列表",
"local": "本地",
"muted_users": "已屏蔽的用户",
"notifications": "通知",
"privacy": "隐私协议",
"profile": "个人资料",
"search": "搜索",
"select_feature_flags": "功能开关",
@ -248,6 +257,8 @@
},
"pwa": {
"dismiss": "忽略",
"install": "安装",
"install_title": "安装鹿鸣",
"title": "鹿鸣存在新的更新",
"update": "更新",
"update_available_short": "更新鹿鸣",
@ -280,7 +291,16 @@
},
"settings": {
"about": {
"label": "关于"
"built_at": "构建于",
"label": "关于",
"meet_the_team": "认识开发团队",
"sponsor_action": "赞助我们",
"sponsor_action_desc": "支持团队开发鹿鸣",
"sponsors": "赞助者",
"sponsors_body_1": "鹿鸣能够出现要感谢以下赞助者的慷慨赞助和帮助:",
"sponsors_body_2": "以及赞助鹿鸣开发团队和成员的所有公司和个人。",
"sponsors_body_3": "如果你喜欢这个应用程序,请考虑赞助我们:",
"version": "版本"
},
"account_settings": {
"description": "在 Mastodon UI 中编辑你的账号设置",
@ -293,19 +313,19 @@
"font_size": "字号",
"label": "外观",
"light_mode": "浅色",
"size_label": {
"lg": "大",
"md": "中",
"sm": "小",
"xl": "特大",
"xs": "特小"
},
"system_mode": "跟随系统",
"theme_color": "主题颜色"
},
"language": {
"display_language": "首选语言",
"label": "语言"
"label": "语言",
"translations": {
"add": "添加",
"choose_language": "选择语言",
"heading": "翻译",
"hide_specific": "隐藏指定的翻译",
"remove": "删除"
}
},
"notifications": {
"label": "通知",
@ -354,16 +374,20 @@
"re_auth": "您的服务器似乎不支持推送通知。尝试退出用户并重新登录。如果此消息仍然出现,请联系您服务器的管理员。"
}
},
"show_btn": "前往通知设置"
"show_btn": "前往通知设置",
"under_construction": "建设中"
},
"notifications_settings": "通知",
"preferences": {
"enable_autoplay": "开启自动播放",
"enable_pinch_to_zoom": "启用双指缩放功能",
"github_cards": "GitHub 卡片",
"grayscale_mode": "灰色模式",
"hide_account_hover_card": "隐藏用户悬浮卡",
"hide_boost_count": "隐藏转发数",
"hide_favorite_count": "隐藏收藏数",
"hide_follower_count": "隐藏关注者数",
"hide_reply_count": "隐藏回复数",
"hide_translation": "隐藏翻译",
"label": "首选项",
"title": "实验功能",
@ -393,6 +417,11 @@
"label": "当前用户"
}
},
"share-target": {
"description": "只需要在你的设备或电脑上安装并登录鹿鸣,通过简单的配置,你就可以从其他应用中分享内容至鹿鸣。",
"hint": "为了分享内容至鹿鸣,你必须安装并登录鹿鸣。",
"title": "分享至鹿鸣"
},
"state": {
"attachments_exceed_server_limit": "附件的数量超出了最大限制",
"attachments_limit_error": "超出每篇帖文的最大限制",
@ -409,6 +438,7 @@
"edited": "在 {0} 编辑了",
"favourited_by": "被喜欢",
"filter_hidden_phrase": "筛选依据",
"filter_removed_phrase": "从筛选中移除",
"filter_show_anyway": "仍然展示",
"img_alt": {
"desc": "描述",
@ -433,8 +463,10 @@
"edited": "在 {0} 编辑了"
},
"tab": {
"accounts": "用户",
"for_you": "推荐关注",
"hashtags": "话题标签",
"list": "列表",
"media": "媒体",
"news": "最新消息",
"notifications_all": "全部",

View file

@ -5,16 +5,18 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!('server' in to.params))
return
const server = to.params.server as string || currentServer.value
const user = currentUser.value
const masto = useMasto()
if (!user) {
if (from.params.server !== to.params.server)
loginTo(masto, { server: to.params.server as string })
const fromServer = from.params.server || currentServer.value
if (fromServer !== server)
loginTo(masto, { server })
return
}
// No need to additionally resolve an id if we're already logged in
if (user.server === to.params.server)
if (user.server === server)
return
// Tags don't need to be redirected to a local id
@ -22,7 +24,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
return
// Handle redirecting to new permalink structure for users with old links
if (!to.params.server) {
if (!useAppConfig().singleInstanceServer && !to.params.server) {
return {
...to,
params: {

View file

@ -0,0 +1,10 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (process.server || !useAppConfig().singleInstanceServer)
return
if (to.params.server) {
const newTo = { ...to }
delete newTo.params.server
return newTo
}
})

View file

@ -1,6 +1,7 @@
export default defineNuxtRouteMiddleware((to) => {
if (process.server)
return
if (to.path === '/signin/callback')
return

View file

@ -19,8 +19,12 @@ export default defineNuxtModule({
env,
}
nuxt.options.runtimeConfig.public.env = env
nuxt.options.runtimeConfig.public.buildInfo = buildInfo
nuxt.options.appConfig = nuxt.options.appConfig || {}
nuxt.options.appConfig.env = env
nuxt.options.appConfig.buildInfo = buildInfo
nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}
nuxt.options.nitro.virtual['#build-info'] = `export const env = ${JSON.stringify(env)}`
nuxt.options.nitro.publicAssets = nuxt.options.nitro.publicAssets || []
if (env === 'dev')

View file

@ -13,6 +13,7 @@ interface ExtendedManifestOptions extends ManifestOptions {
method: string
enctype: string
params: {
title: string
text: string
url: string
files: [{
@ -104,8 +105,9 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'text',
url: 'url',
files: [
{
name: 'files',
@ -150,8 +152,9 @@ export const createI18n = async (): Promise<LocalizedWebManifest> => {
method: 'POST',
enctype: 'multipart/form-data',
params: {
title: 'title',
text: 'text',
url: 'text',
url: 'url',
files: [
{
name: 'files',

View file

@ -23,6 +23,9 @@ export default defineNuxtModule<VitePWANuxtOptions>({
}
let webmanifests: LocalizedWebManifest | undefined
nuxt.options.appConfig = nuxt.options.appConfig || {}
nuxt.options.appConfig.pwaEnabled = !options.disable
// TODO: combine with configurePWAOptions?
nuxt.hook('nitro:init', (nitro) => {
options.outDir = nitro.options.output.publicDir

View file

@ -22,7 +22,9 @@ export default defineNuxtModule({
...nuxt.options.alias,
'unstorage/drivers/fs': 'unenv/runtime/mock/proxy',
'unstorage/drivers/cloudflare-kv-http': 'unenv/runtime/mock/proxy',
'#storage-config': resolve('./runtime/storage-config'),
'node:events': 'unenv/runtime/node/events/index',
'#build-info': resolve('./runtime/build-info'),
}
nuxt.hook('vite:extend', ({ config }) => {

View file

@ -0,0 +1 @@
export const env = useAppConfig().env

View file

@ -55,7 +55,6 @@ export default defineNuxtPlugin(async () => {
const localCall = createCall(toNodeListener(h3App) as any)
const localFetch = createLocalFetch(localCall, globalThis.fetch)
// @ts-expect-error slight differences in api
globalThis.$fetch = createFetch({
// @ts-expect-error slight differences in api
fetch: localFetch,

View file

@ -0,0 +1,2 @@
export const driver = undefined
export const fsBase = ''

View file

@ -1,10 +1,9 @@
import { createResolver } from '@nuxt/kit'
import { createResolver, useNuxt } from '@nuxt/kit'
import Inspect from 'vite-plugin-inspect'
import { isCI, isDevelopment, isWindows } from 'std-env'
import { isPreview } from './config/env'
import { i18n } from './config/i18n'
import { pwa } from './config/pwa'
import type { BuildInfo } from './types'
const { resolve } = createResolver(import.meta.url)
@ -25,6 +24,7 @@ export default defineNuxtConfig({
'@vue-macros/nuxt',
'@nuxtjs/i18n',
'@nuxtjs/color-mode',
'nuxt-vitest',
...(isDevelopment || isWindows) ? [] : ['nuxt-security'],
'~/modules/purge-comments',
'~/modules/setup-components',
@ -66,6 +66,7 @@ export default defineNuxtConfig({
'./composables/settings',
'./composables/tiptap/index.ts',
],
injectAtEnd: true,
},
vite: {
define: {
@ -75,6 +76,15 @@ export default defineNuxtConfig({
},
build: {
target: 'esnext',
rollupOptions: {
output: {
manualChunks: (id) => {
// TODO: find and resolve issue in nuxt/vite/pwa
if (id.includes('.svg') || id.includes('entry'))
return 'entry'
},
},
},
},
plugins: [
Inspect(),
@ -85,6 +95,12 @@ export default defineNuxtConfig({
'postcss-nested': {},
},
},
appConfig: {
singleInstanceServer: process.env.SINGLE_INSTANCE_SERVER === 'true',
storage: {
driver: process.env.NUXT_STORAGE_DRIVER ?? (isCI ? 'cloudflare' : 'fs'),
},
},
runtimeConfig: {
adminKey: '',
cloudflare: {
@ -94,17 +110,14 @@ export default defineNuxtConfig({
},
public: {
privacyPolicyUrl: '',
env: '', // set in build-env module
buildInfo: {} as BuildInfo, // set in build-env module
pwaEnabled: !isDevelopment || process.env.VITE_DEV_PWA === 'true',
// We use LibreTranslate(https://github.com/LibreTranslate/LibreTranslate) as our default translation server #76
// We use LibreTranslate (https://github.com/LibreTranslate/LibreTranslate) as
// our default translation server #76
translateApi: '',
// Use the instance where Elk has its Mastodon account as the default
defaultServer: 'm.webtoo.ls',
},
storage: {
driver: isCI ? 'cloudflare' : 'fs',
fsBase: 'node_modules/.cache/servers',
fsBase: 'node_modules/.cache/app',
},
},
routeRules: {
@ -115,6 +128,9 @@ export default defineNuxtConfig({
},
},
},
build: {
transpile: ['masto'],
},
nitro: {
esbuild: {
options: {
@ -127,11 +143,17 @@ export default defineNuxtConfig({
ignore: ['/settings'],
},
},
hooks: {
'nitro:config': function (config) {
const nuxt = useNuxt()
config.virtual = config.virtual || {}
config.virtual['#storage-config'] = `export const driver = ${JSON.stringify(nuxt.options.appConfig.storage.driver)}`
},
},
app: {
keepalive: true,
head: {
// Prevent arbitrary zooming on mobile devices
viewport: 'width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0,viewport-fit=cover',
viewport: 'width=device-width,initial-scale=1,viewport-fit=cover',
bodyAttrs: {
class: 'overflow-x-hidden',
},
@ -193,3 +215,9 @@ declare global {
}
}
}
declare module 'nuxt/dist/app' {
interface RuntimeNuxtHooks {
'elk-logo:click': () => void
}
}

View file

@ -1,6 +1,6 @@
{
"type": "module",
"version": "0.6.2",
"version": "0.7.2",
"private": true,
"packageManager": "pnpm@7.9.0",
"license": "MIT",
@ -49,6 +49,7 @@
"focus-trap": "^7.2.0",
"form-data": "^4.0.0",
"fuse.js": "^6.6.2",
"github-reserved-names": "^2.0.4",
"idb-keyval": "^6.2.0",
"iso-639-1": "^2.1.15",
"js-yaml": "^4.1.0",
@ -56,7 +57,7 @@
"masto": "^5.6.1",
"pinia": "^2.0.29",
"shiki": "^0.12.1",
"shiki-es": "^0.1.2",
"shiki-es": "^0.2.0",
"slimeform": "^0.9.0",
"string-length": "^5.0.1",
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log",
@ -75,6 +76,7 @@
"@iconify-json/carbon": "^1.1.14",
"@iconify-json/logos": "^1.1.22",
"@iconify-json/material-symbols": "^1.1.26",
"@iconify-json/mdi": "^1.1.44",
"@iconify-json/ph": "^1.1.3",
"@iconify-json/ri": "^1.1.4",
"@iconify-json/twemoji": "^1.1.10",
@ -88,8 +90,8 @@
"@types/js-yaml": "^4.0.5",
"@types/prettier": "^2.7.2",
"@types/wicg-file-system-access": "^2020.9.5",
"@unocss/nuxt": "^0.48.5",
"@vue-macros/nuxt": "^0.3.3",
"@unocss/nuxt": "^0.49.0",
"@vue-macros/nuxt": "^0.3.7",
"@vueuse/math": "^9.11.1",
"@vueuse/nuxt": "^9.11.1",
"bumpp": "^8.2.1",
@ -99,10 +101,10 @@
"esno": "^0.16.3",
"file-saver": "^2.0.5",
"fs-extra": "^11.1.0",
"jsdom": "^21.1.0",
"lint-staged": "^13.1.0",
"nuxt": "3.0.0",
"nuxt": "3.1.1",
"nuxt-security": "^0.10.1",
"nuxt-vitest": "^0.6.4",
"postcss-nested": "^6.0.0",
"prettier": "^2.8.3",
"rollup-plugin-node-polyfills": "^0.2.1",
@ -112,19 +114,18 @@
"std-env": "^3.3.1",
"theme-vitesse": "^0.6.0",
"typescript": "^4.9.4",
"unplugin-auto-import": "^0.12.1",
"unimport": "^2.1.0",
"unplugin-auto-import": "^0.13.0",
"unplugin-vue-inspector": "^0.0.2",
"vite-plugin-inspect": "^0.7.14",
"vite-plugin-pwa": "^0.14.1",
"vitest": "^0.28.1",
"vitest-environment-nuxt": "0.4.0",
"vitest": "^0.28.3",
"vue-tsc": "^1.0.24",
"workbox-build": "^6.5.4",
"workbox-window": "^6.5.4"
},
"pnpm": {
"overrides": {
"mlly": "1.1.0",
"@tiptap/extension-bubble-menu": "2.0.0-beta.204",
"@tiptap/extension-floating-menu": "2.0.0-beta.204",
"@tiptap/core": "2.0.0-beta.204",
@ -146,7 +147,9 @@
"@tiptap/extension-paragraph": "2.0.0-beta.204",
"@tiptap/extension-strike": "2.0.0-beta.204",
"@tiptap/extension-text": "2.0.0-beta.204",
"vitest>vite": "^3.2.5"
"vitest>vite": "^3.2.5",
"@nuxt/kit": "^3.1.1",
"@nuxt/schema": "^3.1.1"
}
},
"simple-git-hooks": {

Some files were not shown because too many files have changed in this diff Show more