Merge branch 'main' into feat/guest
This commit is contained in:
commit
49c4be3b67
169 changed files with 2676 additions and 1293 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ dist
|
||||||
.idea/
|
.idea/
|
||||||
.vite-inspect
|
.vite-inspect
|
||||||
.netlify/
|
.netlify/
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
public/shiki
|
public/shiki
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
|
||||||
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
|
|
@ -1,27 +1,29 @@
|
||||||
{
|
{
|
||||||
"prettier.enable": false,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": true
|
|
||||||
},
|
|
||||||
"files.associations": {
|
|
||||||
"*.css": "postcss"
|
|
||||||
},
|
|
||||||
"editor.formatOnSave": false,
|
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"masto",
|
"masto",
|
||||||
"Nuxtodon",
|
"Nuxtodon",
|
||||||
"unmute",
|
"unmute",
|
||||||
"unstorage"
|
"unstorage"
|
||||||
],
|
],
|
||||||
"i18n-ally.localesPaths": [
|
"editor.codeActionsOnSave": {
|
||||||
"locales"
|
"source.fixAll.eslint": true
|
||||||
],
|
},
|
||||||
"i18n-ally.keystyle": "nested",
|
"editor.formatOnSave": false,
|
||||||
"i18n-ally.sourceLanguage": "en-US",
|
"files.associations": {
|
||||||
"i18n-ally.preferredDelimiter": "_",
|
"*.css": "postcss"
|
||||||
"i18n-ally.sortKeys": true,
|
},
|
||||||
"i18n-ally.keysInUse": [
|
"i18n-ally.keysInUse": [
|
||||||
"time_ago_options.*",
|
"time_ago_options.*",
|
||||||
"visibility.*"
|
"visibility.*"
|
||||||
]
|
],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"locales"
|
||||||
|
],
|
||||||
|
"i18n-ally.preferredDelimiter": "_",
|
||||||
|
"i18n-ally.sortKeys": true,
|
||||||
|
"i18n-ally.sourceLanguage": "en-US",
|
||||||
|
"prettier.enable": false,
|
||||||
|
"volar.completion.preferredTagNameCase": "pascal",
|
||||||
|
"volar.completion.preferredAttrNameCase": "kebab"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
app.vue
9
app.vue
|
|
@ -12,4 +12,13 @@ const key = computed(() => getUniqueUserId(currentUser.value))
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<AriaAnnouncer />
|
<AriaAnnouncer />
|
||||||
|
|
||||||
|
<!-- Avatar Mask -->
|
||||||
|
<svg absolute op0 width="0" height="0">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="avatar-mask" clipPathUnits="objectBoundingBox">
|
||||||
|
<path d="M 0,0.5 C 0,0 0,0 0.5,0 S 1,0 1,0.5 1,1 0.5,1 0,1 0,0.5" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Account } from 'masto'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
square?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loaded = $ref(false)
|
const loaded = $ref(false)
|
||||||
|
|
@ -17,8 +18,8 @@ const error = $ref(false)
|
||||||
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
||||||
:alt="$t('account.avatar_description', [account.username])"
|
:alt="$t('account.avatar_description', [account.username])"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
rounded-full
|
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
|
||||||
:class="loaded ? 'bg-base' : 'bg-gray:10'"
|
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@load="loaded = true"
|
@load="loaded = true"
|
||||||
@error="error = true"
|
@error="error = true"
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ import type { Account } from 'masto'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
square?: boolean
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :key="account.avatar" v-bind="$attrs" rounded-full bg-base w-54px h-54px flex items-center justify-center>
|
<div :key="account.avatar" v-bind="$attrs" rounded-full bg-base w-54px h-54px flex items-center justify-center>
|
||||||
<AccountAvatar :account="account" w-48px h-48px />
|
<AccountAvatar :account="account" w-48px h-48px :square="square" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,7 @@ defineOptions({
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div sm:mt-2>
|
<div sm:mt-2>
|
||||||
<div>
|
<AccountDisplayName :account="account" font-bold text-lg line-clamp-1 ws-pre-wrap break-all />
|
||||||
<ContentRich
|
|
||||||
font-bold text-lg line-clamp-1 ws-pre-wrap break-all
|
|
||||||
:content="getDisplayName(account, { rich: true })"
|
|
||||||
:emojis="account.emojis"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<AccountHandle text-sm :account="account" />
|
<AccountHandle text-sm :account="account" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
15
components/account/AccountDisplayName.vue
Normal file
15
components/account/AccountDisplayName.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Account } from 'masto'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
account: Account
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContentRich
|
||||||
|
:content="getDisplayName(account, { rich: true })"
|
||||||
|
:emojis="account.emojis"
|
||||||
|
:markdown="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Account, Field } from 'masto'
|
import type { Account, Field } from 'masto'
|
||||||
import { getAccountFieldIcon } from '~/composables/masto/icons'
|
|
||||||
|
|
||||||
const { account } = defineProps<{
|
const { account } = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
|
@ -74,14 +73,9 @@ const isSelf = $computed(() => checkAuth(currentUser.value) && currentUser.value
|
||||||
<button w-30 h-30 rounded-full border-4 border-bg-base z-2 @click="previewAvatar">
|
<button w-30 h-30 rounded-full border-4 border-bg-base z-2 @click="previewAvatar">
|
||||||
<AccountAvatar :account="account" hover:opacity-90 transition-opacity />
|
<AccountAvatar :account="account" hover:opacity-90 transition-opacity />
|
||||||
</button>
|
</button>
|
||||||
<div flex flex-col>
|
<div flex="~ col gap1">
|
||||||
<div flex justify-between>
|
<div flex justify-between>
|
||||||
<ContentRich
|
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
|
||||||
font-bold sm:text-2xl text-xl
|
|
||||||
:content="getDisplayName(account, { rich: true })"
|
|
||||||
:emojis="account.emojis"
|
|
||||||
:markdown="false"
|
|
||||||
/>
|
|
||||||
<AccountBotIndicator v-if="account.bot" />
|
<AccountBotIndicator v-if="account.bot" />
|
||||||
</div>
|
</div>
|
||||||
<AccountHandle :account="account" />
|
<AccountHandle :account="account" />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const { account, as = 'div' } = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
as?: string
|
as?: string
|
||||||
hoverCard?: boolean
|
hoverCard?: boolean
|
||||||
|
square?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
|
|
@ -17,15 +18,11 @@ defineOptions({
|
||||||
<template>
|
<template>
|
||||||
<component :is="as" flex gap-3 v-bind="$attrs">
|
<component :is="as" flex gap-3 v-bind="$attrs">
|
||||||
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
||||||
<AccountBigAvatar :account="account" shrink-0 />
|
<AccountBigAvatar :account="account" shrink-0 :square="square" />
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
<div flex="~ col" shrink overflow-hidden justify-center leading-none>
|
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none>
|
||||||
<div flex="~" gap-2>
|
<div flex="~" gap-2>
|
||||||
<ContentRich
|
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
||||||
font-bold line-clamp-1 ws-pre-wrap break-all text-lg
|
|
||||||
:content="getDisplayName(account, { rich: true })"
|
|
||||||
:emojis="account.emojis"
|
|
||||||
/>
|
|
||||||
<AccountBotIndicator v-if="account.bot" />
|
<AccountBotIndicator v-if="account.bot" />
|
||||||
</div>
|
</div>
|
||||||
<AccountHandle :account="account" text-secondary-light />
|
<AccountHandle :account="account" text-secondary-light />
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,7 @@ const { link = true, avatar = true } = defineProps<{
|
||||||
min-w-0 flex gap-2 items-center
|
min-w-0 flex gap-2 items-center
|
||||||
>
|
>
|
||||||
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
||||||
<ContentRich
|
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all />
|
||||||
line-clamp-1 ws-pre-wrap break-all
|
|
||||||
:content="getDisplayName(account, { rich: true })"
|
|
||||||
:emojis="account.emojis"
|
|
||||||
/>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// type used in <template>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
import type { Account } from 'masto'
|
import type { Account } from 'masto'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -14,8 +16,9 @@ defineProps<{
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div flex>
|
<div flex>
|
||||||
<NuxtLink :to="getAccountRoute(account.moved as any)">
|
<!-- type error of masto.js -->
|
||||||
<AccountInfo :account="account.moved" />
|
<NuxtLink :to="getAccountRoute(account.moved as unknown as Account)">
|
||||||
|
<AccountInfo :account="account.moved as unknown as Account" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
<div flex items-center>
|
<div flex items-center>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SearchResult as SearchResultType } from '@/components/search/types'
|
import type { AccountResult, HashTagResult, SearchResult as SearchResultType } from '@/components/search/types'
|
||||||
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
|
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -37,11 +37,23 @@ const searchResult = $computed<QueryResult>(() => {
|
||||||
if (query.length === 0 || loading.value)
|
if (query.length === 0 || loading.value)
|
||||||
return { length: 0, items: [], grouped: {} as any }
|
return { length: 0, items: [], grouped: {} as any }
|
||||||
|
|
||||||
|
// TODO extract this scope
|
||||||
|
// duplicate in SearchWidget.vue
|
||||||
const hashtagList = hashtags.value.slice(0, 3)
|
const hashtagList = hashtags.value.slice(0, 3)
|
||||||
.map<SearchResultType>(hashtag => ({ type: 'hashtag', hashtag, to: `/tags/${hashtag.name}` }))
|
.map<HashTagResult>(hashtag => ({
|
||||||
|
type: 'hashtag',
|
||||||
|
id: hashtag.id,
|
||||||
|
hashtag,
|
||||||
|
to: getTagRoute(hashtag.name),
|
||||||
|
}))
|
||||||
.map(toSearchQueryResultItem)
|
.map(toSearchQueryResultItem)
|
||||||
const accountList = accounts.value
|
const accountList = accounts.value
|
||||||
.map<SearchResultType>(account => ({ type: 'account', account, to: `/@${account.acct}` }))
|
.map<AccountResult>(account => ({
|
||||||
|
type: 'account',
|
||||||
|
id: account.id,
|
||||||
|
account,
|
||||||
|
to: getAccountRoute(account),
|
||||||
|
}))
|
||||||
.map(toSearchQueryResultItem)
|
.map(toSearchQueryResultItem)
|
||||||
|
|
||||||
const grouped: QueryResult['grouped'] = new Map()
|
const grouped: QueryResult['grouped'] = new Map()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue?: boolean
|
|
||||||
}>(), {
|
|
||||||
modelValue: true,
|
|
||||||
})
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
const { modelValue: visible } = defineModel<{
|
||||||
|
modelValue?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { decode } from 'blurhash'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
inheritAttrs: false,
|
|
||||||
props: {
|
|
||||||
blurhash: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
src: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
srcset: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup(props, { attrs }) {
|
|
||||||
const placeholderSrc = ref<string>()
|
|
||||||
const isLoaded = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const img = document.createElement('img')
|
|
||||||
img.onload = () => {
|
|
||||||
isLoaded.value = true
|
|
||||||
}
|
|
||||||
img.src = props.src
|
|
||||||
if (props.srcset)
|
|
||||||
img.srcset = props.srcset
|
|
||||||
setTimeout(() => {
|
|
||||||
isLoaded.value = true
|
|
||||||
}, 3_000)
|
|
||||||
|
|
||||||
if (props.blurhash) {
|
|
||||||
const pixels = decode(props.blurhash, 32, 32)
|
|
||||||
placeholderSrc.value = getDataUrlFromArr(pixels, 32, 32)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => isLoaded.value || !placeholderSrc.value
|
|
||||||
? h('img', { ...attrs, src: props.src, srcset: props.srcset })
|
|
||||||
: h('img', { ...attrs, src: placeholderSrc.value })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
43
components/common/CommonBlurhash.vue
Normal file
43
components/common/CommonBlurhash.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { decode } from 'blurhash'
|
||||||
|
|
||||||
|
const { blurhash, src, srcset } = defineProps<{
|
||||||
|
blurhash?: string | null | undefined
|
||||||
|
src: string
|
||||||
|
srcset?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoaded = ref(false)
|
||||||
|
const placeholderSrc = $computed(() => {
|
||||||
|
if (!blurhash)
|
||||||
|
return ''
|
||||||
|
const pixels = decode(blurhash, 32, 32)
|
||||||
|
return getDataUrlFromArr(pixels, 32, 32)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
isLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = src
|
||||||
|
|
||||||
|
if (srcset)
|
||||||
|
img.srcset = srcset
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoaded.value = true
|
||||||
|
}, 3_000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img v-if="isLoaded || !placeholderSrc" v-bind="$attrs" :src="src" :srcset="srcset">
|
||||||
|
<img v-else v-bind="$attrs" :src="placeholderSrc">
|
||||||
|
</template>
|
||||||
|
|
@ -11,11 +11,13 @@ const { modelValue } = defineModel<{
|
||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
||||||
:class="hover ? 'hover:bg-active ms--2 ps-4' : null"
|
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
||||||
@click.prevent="modelValue = !modelValue"
|
@click.prevent="modelValue = !modelValue"
|
||||||
>
|
>
|
||||||
|
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
||||||
|
text-lg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|
@ -23,7 +25,6 @@ const { modelValue } = defineModel<{
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
sr-only
|
sr-only
|
||||||
>
|
>
|
||||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { Cropper } from 'vue-advanced-cropper'
|
||||||
import 'vue-advanced-cropper/dist/style.css'
|
import 'vue-advanced-cropper/dist/style.css'
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
/** Images to be cropped */
|
|
||||||
modelValue?: File
|
|
||||||
/** Crop frame aspect ratio (width/height), default 1/1 */
|
/** Crop frame aspect ratio (width/height), default 1/1 */
|
||||||
stencilAspectRatio?: number
|
stencilAspectRatio?: number
|
||||||
/** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */
|
/** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */
|
||||||
|
|
@ -16,12 +14,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
stencilSizePercentage: 0.9,
|
stencilSizePercentage: 0.9,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const { modelValue: file } = defineModel<{
|
||||||
(event: 'update:modelValue', value: File): void
|
/** Images to be cropped */
|
||||||
|
modelValue: File | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const vmFile = useVModel(props, 'modelValue', emit, { passive: true })
|
|
||||||
|
|
||||||
const cropperDialog = ref(false)
|
const cropperDialog = ref(false)
|
||||||
|
|
||||||
const cropper = ref<InstanceType<typeof Cropper>>()
|
const cropper = ref<InstanceType<typeof Cropper>>()
|
||||||
|
|
@ -40,7 +37,7 @@ const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(vmFile, (file, _, onCleanup) => {
|
watch(file, (file, _, onCleanup) => {
|
||||||
let expired = false
|
let expired = false
|
||||||
onCleanup(() => expired = true)
|
onCleanup(() => expired = true)
|
||||||
|
|
||||||
|
|
@ -59,12 +56,12 @@ watch(vmFile, (file, _, onCleanup) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const cropImage = () => {
|
const cropImage = () => {
|
||||||
if (cropper.value && vmFile.value) {
|
if (cropper.value && file.value) {
|
||||||
cropperFlag.value = true
|
cropperFlag.value = true
|
||||||
cropperDialog.value = false
|
cropperDialog.value = false
|
||||||
const { canvas } = cropper.value.getResult()
|
const { canvas } = cropper.value.getResult()
|
||||||
canvas?.toBlob((blob) => {
|
canvas?.toBlob((blob) => {
|
||||||
vmFile.value = new File([blob as any], `cropped${vmFile.value?.name}` as string, { type: blob?.type })
|
file.value = new File([blob as any], `cropped${file.value?.name}` as string, { type: blob?.type })
|
||||||
}, cropperImage.type)
|
}, cropperImage.type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { fileOpen } from 'browser-fs-access'
|
||||||
import type { FileWithHandle } from 'browser-fs-access'
|
import type { FileWithHandle } from 'browser-fs-access'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue?: FileWithHandle
|
|
||||||
/** The image src before change */
|
/** The image src before change */
|
||||||
original?: string
|
original?: string
|
||||||
/** Allowed file types */
|
/** Allowed file types */
|
||||||
|
|
@ -19,12 +18,13 @@ const props = withDefaults(defineProps<{
|
||||||
allowedFileSize: 1024 * 1024 * 5, // 5 MB
|
allowedFileSize: 1024 * 1024 * 5, // 5 MB
|
||||||
})
|
})
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: FileWithHandle): void
|
|
||||||
(event: 'pick', value: FileWithHandle): void
|
(event: 'pick', value: FileWithHandle): void
|
||||||
(event: 'error', code: number, message: string): void
|
(event: 'error', code: number, message: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const file = useVModel(props, 'modelValue', emit, { passive: true })
|
const { modelValue: file } = defineModel<{
|
||||||
|
modelValue: FileWithHandle | null
|
||||||
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// @ts-expect-error missing types
|
// @ts-expect-error missing types
|
||||||
import { DynamicScroller } from 'vue-virtual-scroller'
|
import { DynamicScroller } from 'vue-virtual-scroller'
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
import type { Account, Paginator, WsEvents } from 'masto'
|
import type { Paginator, WsEvents } from 'masto'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
paginator,
|
paginator,
|
||||||
|
|
@ -11,7 +11,6 @@ const {
|
||||||
virtualScroller = false,
|
virtualScroller = false,
|
||||||
eventType = 'update',
|
eventType = 'update',
|
||||||
preprocess,
|
preprocess,
|
||||||
isAccountTimeline,
|
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
paginator: Paginator<any, any[]>
|
paginator: Paginator<any, any[]>
|
||||||
keyProp?: string
|
keyProp?: string
|
||||||
|
|
@ -19,7 +18,6 @@ const {
|
||||||
stream?: Promise<WsEvents>
|
stream?: Promise<WsEvents>
|
||||||
eventType?: 'notification' | 'update'
|
eventType?: 'notification' | 'update'
|
||||||
preprocess?: (items: any[]) => any[]
|
preprocess?: (items: any[]) => any[]
|
||||||
isAccountTimeline?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
|
|
@ -34,18 +32,9 @@ defineSlots<{
|
||||||
update: () => void
|
update: () => void
|
||||||
}
|
}
|
||||||
loading: {}
|
loading: {}
|
||||||
|
done: {}
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let account: Account | null = null
|
|
||||||
|
|
||||||
const { params } = useRoute()
|
|
||||||
|
|
||||||
if (isAccountTimeline) {
|
|
||||||
const handle = $(computedEager(() => params.account as string))
|
|
||||||
|
|
||||||
account = await fetchAccountByHandle(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess)
|
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -84,15 +73,11 @@ const { items, prevItems, update, state, endAnchor, error } = usePaginator(pagin
|
||||||
<slot v-if="state === 'loading'" name="loading">
|
<slot v-if="state === 'loading'" name="loading">
|
||||||
<TimelineSkeleton />
|
<TimelineSkeleton />
|
||||||
</slot>
|
</slot>
|
||||||
<div v-else-if="state === 'done'" p5 text-secondary italic text-center flex flex-col items-center gap1>
|
<slot v-else-if="state === 'done'" name="done">
|
||||||
<template v-if="isAccountTimeline">
|
<div p5 text-secondary italic text-center>
|
||||||
<span>{{ $t('timeline.view_older_posts') }}</span>
|
|
||||||
<a :href="account!.url" not-italic text-primary hover="underline text-primary-active">{{ $t('menu.open_in_original_site') }}</a>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ $t('common.end_of_list') }}
|
{{ $t('common.end_of_list') }}
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</slot>
|
||||||
<div v-else-if="state === 'error'" p5 text-secondary>
|
<div v-else-if="state === 'error'" p5 text-secondary>
|
||||||
{{ $t('common.error') }}: {{ error }}
|
{{ $t('common.error') }}: {{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ const { modelValue } = defineModel<{
|
||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
||||||
:class="hover ? 'hover:bg-active ms--2 ps-4' : null"
|
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
||||||
@click.prevent="modelValue = value"
|
@click.prevent="modelValue = value"
|
||||||
>
|
>
|
||||||
|
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
|
:class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
@ -25,7 +26,6 @@ const { modelValue } = defineModel<{
|
||||||
:value="value"
|
:value="value"
|
||||||
sr-only
|
sr-only
|
||||||
>
|
>
|
||||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ useCommands(() => command
|
||||||
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
||||||
@click="!preventScrollTop && $scrollToTop()"
|
@click="!preventScrollTop && $scrollToTop()"
|
||||||
>
|
>
|
||||||
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display }}</span>
|
<span ws-nowrap mxa sm:px2 sm:py3 xl:pb4 xl:pt5 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div v-else flex flex-auto sm:px6 px2>
|
<div v-else flex flex-auto sm:px6 px2 xl:pb4 xl:pt5>
|
||||||
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
|
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ import sparkline from '@fnando/sparkline'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
history,
|
history,
|
||||||
|
width = 60,
|
||||||
|
height = 40,
|
||||||
} = $defineProps<{
|
} = $defineProps<{
|
||||||
history?: History[]
|
history?: History[]
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const historyNum = $computed(() => {
|
const historyNum = $computed(() => {
|
||||||
|
|
@ -24,5 +28,5 @@ watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg ref="sparklineEl" class="sparkline" width="60" height="40" stroke-width="3" />
|
<svg ref="sparklineEl" class="sparkline" :width="width" :height="height" stroke-width="3" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const emit = defineEmits<{
|
||||||
<p flex="~ gap-2 wrap" mxa>
|
<p flex="~ gap-2 wrap" mxa>
|
||||||
<template v-for="team of teams" :key="team.github">
|
<template v-for="team of teams" :key="team.github">
|
||||||
<a :href="`https://github.com/sponsors/${team.github}`" target="_blank" rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
|
<a :href="`https://github.com/sponsors/${team.github}`" target="_blank" rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
|
||||||
<img :src="`https://res.cloudinary.com/dchoja2nb/image/twitter_name/h_120,w_120/f_auto/${team.twitter}.jpg`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
<img :src="`/avatars/${team.github}-100x100.png`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ defineProps<{
|
||||||
pt="[env(safe-area-inset-top,0)]"
|
pt="[env(safe-area-inset-top,0)]"
|
||||||
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]"
|
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]"
|
||||||
>
|
>
|
||||||
<div flex justify-between px5 py2>
|
<div xl:hidden flex justify-between px5 py2>
|
||||||
<div flex gap-3 items-center overflow-hidden py2>
|
<div flex gap-3 items-center overflow-hidden py2>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0
|
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0
|
||||||
:class="{ 'lg:hidden': backOnSmallScreen }"
|
:class="{ 'lg:hidden': backOnSmallScreen }"
|
||||||
|
:aria-label="$t('nav.back')"
|
||||||
@click="$router.go(-1)"
|
@click="$router.go(-1)"
|
||||||
>
|
>
|
||||||
<div i-ri:arrow-left-line class="rtl-flip" />
|
<div i-ri:arrow-left-line class="rtl-flip" />
|
||||||
|
|
@ -37,6 +38,7 @@ defineProps<{
|
||||||
</div>
|
</div>
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
|
<div hidden xl:block h-6 />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ const handlePublishClose = () => {
|
||||||
>
|
>
|
||||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||||
<PublishWidget
|
<PublishWidget
|
||||||
|
v-if="dialogDraftKey"
|
||||||
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
||||||
@published="handlePublished"
|
@published="handlePublished"
|
||||||
/>
|
/>
|
||||||
|
|
@ -65,7 +66,7 @@ const handlePublishClose = () => {
|
||||||
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
|
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
|
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
|
||||||
<StatusEditPreview :edit="statusEdit" />
|
<StatusEditPreview v-if="statusEdit" :edit="statusEdit" />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
|
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
|
||||||
<CommandPanel @close="closeCommandPanel()" />
|
<CommandPanel @close="closeCommandPanel()" />
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
/** v-model dislog visibility */
|
|
||||||
modelValue: boolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* level of depth
|
* level of depth
|
||||||
*
|
*
|
||||||
|
|
@ -48,11 +45,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** v-model dialog visibility */
|
/** v-model dialog visibility */
|
||||||
(event: 'update:modelValue', value: boolean): void
|
|
||||||
(event: 'close',): void
|
(event: 'close',): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
const { modelValue: visible } = defineModel<{
|
||||||
|
/** v-model dislog visibility */
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const deactivated = useDeactivated()
|
const deactivated = useDeactivated()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,20 @@ import { SwipeDirection } from '@vueuse/core'
|
||||||
import { useReducedMotion } from '@vueuse/motion'
|
import { useReducedMotion } from '@vueuse/motion'
|
||||||
import type { Attachment } from 'masto'
|
import type { Attachment } from 'masto'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ media: Attachment[]; threshold?: number; modelValue: number }>(), {
|
const { media = [], threshold = 20 } = defineProps<{
|
||||||
media: [] as any,
|
media?: Attachment[]
|
||||||
threshold: 20,
|
threshold?: number
|
||||||
modelValue: 0,
|
}>()
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { modelValue } = defineModel<{
|
||||||
|
modelValue: number
|
||||||
|
}>()
|
||||||
|
|
||||||
const target = ref()
|
const target = ref()
|
||||||
const index = useVModel(props, 'modelValue', emit)
|
|
||||||
|
|
||||||
const animateTimeout = useTimeout(10)
|
const animateTimeout = useTimeout(10)
|
||||||
const reduceMotion = useReducedMotion()
|
const reduceMotion = useReducedMotion()
|
||||||
|
|
@ -28,15 +29,15 @@ const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
|
||||||
passive: false,
|
passive: false,
|
||||||
onSwipeEnd(e, direction) {
|
onSwipeEnd(e, direction) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > props.threshold)
|
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold)
|
||||||
index.value = Math.max(0, index.value - 1)
|
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > props.threshold)
|
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold)
|
||||||
index.value = Math.min(props.media.length - 1, index.value + 1)
|
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > props.threshold)
|
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > threshold)
|
||||||
emit('close')
|
emit('close')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -46,9 +47,9 @@ const distanceX = computed(() => {
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
|
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
|
||||||
return index.value * 100 * -1
|
return modelValue.value * 100 * -1
|
||||||
|
|
||||||
return (lengthX.value / width.value) * 100 * -1 + (index.value * 100) * -1
|
return (lengthX.value / width.value) * 100 * -1 + (modelValue.value * 100) * -1
|
||||||
})
|
})
|
||||||
|
|
||||||
const distanceY = computed(() => {
|
const distanceY = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ const moreMenuVisible = ref(false)
|
||||||
<div i-ri:earth-line />
|
<div i-ri:earth-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<NavBottomMoreMenu v-slot="{ changeShow, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
<NavBottomMoreMenu v-slot="{ toggleVisible, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
||||||
<label
|
<label
|
||||||
flex items-center place-content-center h-full flex-1 class="select-none"
|
flex items-center place-content-center h-full flex-1 class="select-none"
|
||||||
:class="show ? '!text-primary' : ''"
|
:class="show ? '!text-primary' : ''"
|
||||||
>
|
>
|
||||||
<input type="checkbox" z="-1" absolute inset-0 opacity-0 @click="changeShow">
|
<input type="checkbox" z="-1" absolute inset-0 opacity-0 @click="toggleVisible">
|
||||||
<span v-show="show" i-ri:close-fill />
|
<span v-show="show" i-ri:close-fill />
|
||||||
<span v-show="!show" i-ri:more-fill />
|
<span v-show="!show" i-ri:more-fill />
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps<{
|
let { modelValue } = $defineModel<{
|
||||||
modelValue?: boolean
|
modelValue: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'update:modelValue', value: boolean): void
|
|
||||||
}>()
|
|
||||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
function changeShow() {
|
function toggleVisible() {
|
||||||
visible.value = !visible.value
|
modelValue = !modelValue
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonEl = ref<HTMLDivElement>()
|
const buttonEl = ref<HTMLDivElement>()
|
||||||
/** Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened */
|
/** Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened */
|
||||||
function clickEvent(mouse: MouseEvent) {
|
function clickEvent(mouse: MouseEvent) {
|
||||||
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
||||||
if (visible.value) {
|
if (modelValue) {
|
||||||
document.removeEventListener('click', clickEvent)
|
document.removeEventListener('click', clickEvent)
|
||||||
visible.value = false
|
modelValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +23,7 @@ function toggleDark() {
|
||||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(visible, (val) => {
|
watch($$(modelValue), (val) => {
|
||||||
if (val && typeof document !== 'undefined')
|
if (val && typeof document !== 'undefined')
|
||||||
document.addEventListener('click', clickEvent)
|
document.addEventListener('click', clickEvent)
|
||||||
})
|
})
|
||||||
|
|
@ -39,7 +35,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="buttonEl" flex items-center static>
|
<div ref="buttonEl" flex items-center static>
|
||||||
<slot :change-show="changeShow" :show="visible" />
|
<slot :toggle-visible="toggleVisible" :show="modelValue" />
|
||||||
|
|
||||||
<!-- Drawer -->
|
<!-- Drawer -->
|
||||||
<Transition
|
<Transition
|
||||||
|
|
@ -51,7 +47,7 @@ onBeforeUnmount(() => {
|
||||||
leave-to-class="opacity-0 children:(transform translate-y-full)"
|
leave-to-class="opacity-0 children:(transform translate-y-full)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-show="visible"
|
v-show="modelValue"
|
||||||
absolute inset-x-0 top-auto bottom-full z-20 h-100vh
|
absolute inset-x-0 top-auto bottom-full z-20 h-100vh
|
||||||
flex items-end of-y-scroll of-x-hidden scrollbar-hide overscroll-none
|
flex items-end of-y-scroll of-x-hidden scrollbar-hide overscroll-none
|
||||||
bg="black/50"
|
bg="black/50"
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ function toggleDark() {
|
||||||
<button
|
<button
|
||||||
flex
|
flex
|
||||||
text-lg
|
text-lg
|
||||||
:class="isZenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
:class="userSettings.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
||||||
:aria-label="$t('nav.zen_mode')"
|
:aria-label="$t('nav.zen_mode')"
|
||||||
@click="toggleZenMode()"
|
@click="userSettings.zenMode = !userSettings.zenMode"
|
||||||
/>
|
/>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@ const { notifications } = useNotifications()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav sm:px3 sm:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
|
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg>
|
||||||
|
<div shrink hidden sm:block mt-4 />
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<div shrink hidden sm:block mt-4 />
|
||||||
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
<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">
|
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
|
|
@ -18,16 +23,17 @@ const { notifications } = useNotifications()
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NavSideItem>
|
</NavSideItem>
|
||||||
|
|
||||||
<!-- Use Search for small screens once the right sidebar is collapsed -->
|
|
||||||
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" xl:hidden :command="command" />
|
|
||||||
<NavSideItem :text="$t('nav.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" :command="command" />
|
|
||||||
|
|
||||||
<NavSideItem :text="$t('nav.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " :command="command" />
|
|
||||||
<NavSideItem :text="$t('nav.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" />
|
|
||||||
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
||||||
|
<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="`/${currentServer}/explore`" icon="i-ri:hashtag" :command="command" />
|
||||||
|
<NavSideItem :text="$t('nav.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " :command="command" />
|
||||||
|
<NavSideItem :text="$t('nav.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" />
|
||||||
|
|
||||||
|
<div shrink hidden sm:block mt-4 />
|
||||||
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,11 @@ useCommand({
|
||||||
|
|
||||||
let activeClass = $ref('text-primary')
|
let activeClass = $ref('text-primary')
|
||||||
onMastoInit(async () => {
|
onMastoInit(async () => {
|
||||||
if (!props.userOnly) {
|
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
// we don't have currentServer defined until later
|
||||||
// we don't have currentServer defined until later
|
activeClass = ''
|
||||||
activeClass = ''
|
await nextTick()
|
||||||
await nextTick()
|
activeClass = 'text-primary'
|
||||||
activeClass = 'text-primary'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
||||||
|
|
@ -58,11 +56,11 @@ const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly &
|
||||||
<CommonTooltip :disabled="!isMediumScreen" :content="text" placement="right">
|
<CommonTooltip :disabled="!isMediumScreen" :content="text" placement="right">
|
||||||
<div
|
<div
|
||||||
flex items-center gap4
|
flex items-center gap4
|
||||||
w-fit rounded-full
|
w-fit rounded-3
|
||||||
px2 py2 mx3 sm:mxa
|
px2 py2 mx3 sm:mxa
|
||||||
xl="mx0 px5"
|
xl="ml0 mr5 px5 w-auto"
|
||||||
transition-100
|
transition-100
|
||||||
group-hover:bg-active group-focus-visible:ring="2 current"
|
group-hover="bg-active" group-focus-visible:ring="2 current"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
<div :class="icon" text-xl />
|
<div :class="icon" text-xl />
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,27 @@ const { env } = buildInfo
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Use external to force refresh page and jump to top of timeline -->
|
<!-- Use external to force refresh page and jump to top of timeline -->
|
||||||
<NuxtLink
|
<div flex justify-between>
|
||||||
flex items-end gap-2
|
<NuxtLink
|
||||||
w-fit
|
flex items-end gap-4
|
||||||
py2 px-2 xl:px-3
|
py2 px-5
|
||||||
text-2xl hover:bg-active
|
text-2xl
|
||||||
focus-visible:ring="2 current"
|
focus-visible:ring="2 current"
|
||||||
rounded-full
|
to="/"
|
||||||
to="/"
|
external
|
||||||
external
|
>
|
||||||
>
|
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
|
||||||
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
|
<div hidden xl:block>
|
||||||
<div hidden xl:block>
|
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
||||||
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
<div hidden xl:flex items-center me-8 mt-2>
|
||||||
|
<NuxtLink
|
||||||
|
:aria-label="$t('nav.back')"
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
>
|
||||||
|
<div i-ri:arrow-left-line class="rtl-flip" btn-text />
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
h-8
|
h-8
|
||||||
w-8
|
w-8
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
|
square
|
||||||
/>
|
/>
|
||||||
<div v-else bg="gray/40" rounded-full w-8 h-8 flex items-center justify-center text-5>
|
<div v-else bg="gray/40" rounded-full w-8 h-8 flex items-center justify-center text-5>
|
||||||
G
|
G
|
||||||
|
|
@ -18,7 +19,7 @@
|
||||||
<UserSwitcher ref="switcher" @click="hide()" />
|
<UserSwitcher ref="switcher" @click="hide()" />
|
||||||
</template>
|
</template>
|
||||||
</VDropdown>
|
</VDropdown>
|
||||||
<button v-else btn-solid text-sm px-2 py-1 text-center lg:hidden @click="openSigninDialog()">
|
<button v-else btn-solid text-sm px-2 py-1 text-center xl:hidden @click="openSigninDialog()">
|
||||||
{{ $t('action.sign_in') }}
|
{{ $t('action.sign_in') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,7 @@ const { notification } = defineProps<{
|
||||||
:lang="notification.status?.language ?? undefined"
|
:lang="notification.status?.language ?? undefined"
|
||||||
>
|
>
|
||||||
<div i-ri:user-follow-fill me-1 color-primary />
|
<div i-ri:user-follow-fill me-1 color-primary />
|
||||||
<ContentRich
|
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
|
||||||
:content="getDisplayName(notification.account, { rich: true })"
|
|
||||||
:emojis="notification.account.emojis"
|
|
||||||
/>
|
|
||||||
<span ws-nowrap>
|
<span ws-nowrap>
|
||||||
{{ $t('notification.followed_you') }}
|
{{ $t('notification.followed_you') }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -36,10 +32,9 @@ const { notification } = defineProps<{
|
||||||
<template v-else-if="notification.type === 'admin.sign_up'">
|
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||||
<div flex p3 items-center bg-shaded>
|
<div flex p3 items-center bg-shaded>
|
||||||
<div i-ri:admin-fill me-1 color-purple />
|
<div i-ri:admin-fill me-1 color-purple />
|
||||||
<ContentRich
|
<AccountDisplayName
|
||||||
|
:account="notification.account"
|
||||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
:content="getDisplayName(notification.account, { rich: true })"
|
|
||||||
:emojis="notification.account.emojis"
|
|
||||||
/>
|
/>
|
||||||
<span>{{ $t("notification.signed_up") }}</span>
|
<span>{{ $t("notification.signed_up") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showReAuthMessage: boolean
|
closeableHeader?: boolean
|
||||||
withHeader?: boolean
|
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -16,15 +15,22 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ col" gap-y-2 role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
|
<div
|
||||||
<header v-if="withHeader" flex items-center pb-2>
|
flex="~ col"
|
||||||
|
gap-y-2
|
||||||
|
role="alert"
|
||||||
|
aria-labelledby="notifications-warning"
|
||||||
|
:class="closeableHeader ? 'border-b border-base' : 'px6 px4'"
|
||||||
|
>
|
||||||
|
<header flex items-center pb-2>
|
||||||
<h2 id="notifications-warning" text-md font-bold w-full>
|
<h2 id="notifications-warning" text-md font-bold w-full>
|
||||||
{{ $t('notification.settings.warning.enable_title') }}
|
{{ $t('settings.notifications.push_notifications.warning.enable_title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
v-if="closeableHeader"
|
||||||
flex rounded-4
|
flex rounded-4
|
||||||
type="button"
|
type="button"
|
||||||
:title="$t('notification.settings.warning.enable_close')"
|
:title="$t('settings.notifications.push_notifications.warning.enable_close')"
|
||||||
hover:bg-active cursor-pointer transition-100
|
hover:bg-active cursor-pointer transition-100
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="$emit('hide')"
|
@click="$emit('hide')"
|
||||||
|
|
@ -33,10 +39,10 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>
|
||||||
{{ $t(withHeader ? 'notification.settings.warning.enable_description' : 'notification.settings.warning.enable_description_short') }}
|
{{ $t(`settings.notifications.push_notifications.warning.enable_description${closeableHeader ? '' : '_settings'}`) }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="isLegacyAccount && showReAuthMessage">
|
<p v-if="isLegacyAccount">
|
||||||
{{ $t('notification.settings.warning.re_auth') }}
|
{{ $t('settings.notifications.push_notifications.warning.re_auth') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
|
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
|
||||||
|
|
@ -46,8 +52,8 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
||||||
@click="$emit('subscribe')"
|
@click="$emit('subscribe')"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
|
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
|
||||||
{{ $t('notification.settings.warning.enable_desktop') }}
|
{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}
|
||||||
</button>
|
</button>
|
||||||
<slot v-if="showReAuthMessage" name="error" />
|
<slot name="error" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,9 @@ const lang = $computed(() => {
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ContentRich
|
<AccountDisplayName
|
||||||
|
:account="items.items[0]?.account"
|
||||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
:content="getDisplayName(items.items[0]?.account, { rich: true })"
|
|
||||||
:emojis="items.items[0]?.account.emojis"
|
|
||||||
/>
|
/>
|
||||||
<span me-1 ws-nowrap>
|
<span me-1 ws-nowrap>
|
||||||
{{ $t('notification.followed_you') }}
|
{{ $t('notification.followed_you') }}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// type used in <template>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
import type { Notification, Paginator, WsEvents } from 'masto'
|
import type { Notification, Paginator, WsEvents } from 'masto'
|
||||||
import type { GroupedAccountLike, NotificationSlot } from '~/types'
|
// type used in <template>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
import type { GroupedAccountLike, GroupedLikeNotifications, NotificationSlot } from '~/types'
|
||||||
|
|
||||||
const { paginator, stream } = defineProps<{
|
const { paginator, stream } = defineProps<{
|
||||||
paginator: Paginator<any, Notification[]>
|
paginator: Paginator<any, Notification[]>
|
||||||
|
|
@ -118,12 +122,12 @@ const { formatNumber } = useHumanReadableNumber()
|
||||||
/>
|
/>
|
||||||
<NotificationGroupedLikes
|
<NotificationGroupedLikes
|
||||||
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
||||||
:group="item"
|
:group="item as GroupedLikeNotifications"
|
||||||
border="b base"
|
border="b base"
|
||||||
/>
|
/>
|
||||||
<NotificationCard
|
<NotificationCard
|
||||||
v-else
|
v-else
|
||||||
:notification="item"
|
:notification="item as Notification"
|
||||||
hover:bg-active
|
hover:bg-active
|
||||||
border="b base"
|
border="b base"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NotificationSubscribePushNotificationError
|
defineProps<{ show?: boolean }>()
|
||||||
from '~/components/notification/NotificationSubscribePushNotificationError.vue'
|
|
||||||
|
|
||||||
defineProps<{ show: boolean }>()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pushNotificationData,
|
pushNotificationData,
|
||||||
|
|
@ -71,12 +68,12 @@ const doSubscribe = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await subscribe()
|
const result = await subscribe()
|
||||||
if (result !== 'subscribed') {
|
if (result !== 'subscribed') {
|
||||||
subscribeError = t(`notification.settings.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
||||||
showSubscribeError = true
|
showSubscribeError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
subscribeError = t('notification.settings.subscription_error.request_error')
|
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||||
showSubscribeError = true
|
showSubscribeError = true
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|
@ -103,40 +100,41 @@ onActivated(() => (busy = false))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="pwaEnabled && (showWarning || show)">
|
<section v-if="pwaEnabled && (showWarning || show)" aria-labelledby="pn-s">
|
||||||
<Transition name="slide-down">
|
<Transition name="slide-down">
|
||||||
<div v-if="show" flex="~ col" border="b base" px5 py4>
|
<div v-if="show" flex="~ col" border="b base">
|
||||||
<header flex items-center pb-2>
|
<h3 id="pn-settings" px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||||
<h2 id="notifications-title" text-md font-bold w-full>
|
{{ $t('settings.notifications.push_notifications.label') }}
|
||||||
{{ $t('notification.settings.title') }}
|
</h3>
|
||||||
</h2>
|
|
||||||
</header>
|
|
||||||
<template v-if="isSupported">
|
<template v-if="isSupported">
|
||||||
<div v-if="isSubscribed" flex="~ col">
|
<div v-if="isSubscribed" flex="~ col">
|
||||||
<form flex="~ col" gap-y-2 @submit.prevent="saveSettings">
|
<form flex="~ col" gap-y-2 px6 pb4 @submit.prevent="saveSettings">
|
||||||
|
<p id="pn-instructions" text-sm pb2 aria-hidden="true">
|
||||||
|
{{ $t('settings.notifications.push_notifications.instructions') }}
|
||||||
|
</p>
|
||||||
<fieldset flex="~ col" gap-y-1 py-1>
|
<fieldset flex="~ col" gap-y-1 py-1>
|
||||||
<legend>{{ $t('notification.settings.alerts.title') }}</legend>
|
<legend>{{ $t('settings.notifications.push_notifications.alerts.title') }}</legend>
|
||||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('notification.settings.alerts.follow')" />
|
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('settings.notifications.push_notifications.alerts.follow')" />
|
||||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('notification.settings.alerts.favourite')" />
|
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('settings.notifications.push_notifications.alerts.favourite')" />
|
||||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('notification.settings.alerts.reblog')" />
|
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('settings.notifications.push_notifications.alerts.reblog')" />
|
||||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('notification.settings.alerts.mention')" />
|
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('settings.notifications.push_notifications.alerts.mention')" />
|
||||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('notification.settings.alerts.poll')" />
|
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('settings.notifications.push_notifications.alerts.poll')" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset flex="~ col" gap-y-1 py-1>
|
<fieldset flex="~ col" gap-y-1 py-1>
|
||||||
<legend>{{ $t('notification.settings.policy.title') }}</legend>
|
<legend>{{ $t('settings.notifications.push_notifications.policy.title') }}</legend>
|
||||||
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('notification.settings.policy.all')" />
|
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('settings.notifications.push_notifications.policy.all')" />
|
||||||
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('notification.settings.policy.followed')" />
|
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('settings.notifications.push_notifications.policy.followed')" />
|
||||||
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('notification.settings.policy.follower')" />
|
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('settings.notifications.push_notifications.policy.follower')" />
|
||||||
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('notification.settings.policy.none')" />
|
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('settings.notifications.push_notifications.policy.none')" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div flex="~ col" gap-y-4 py-1 sm="~ justify-between flex-row">
|
<div flex="~ col" gap-y-4 gap-x-2 py-1 sm="~ justify-between flex-row">
|
||||||
<button
|
<button
|
||||||
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||||
:disabled="busy || !saveEnabled"
|
:disabled="busy || !saveEnabled"
|
||||||
>
|
>
|
||||||
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" />
|
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" />
|
||||||
{{ $t('notification.settings.save_settings') }}
|
{{ $t('settings.notifications.push_notifications.save_settings') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||||
|
|
@ -146,7 +144,7 @@ onActivated(() => (busy = false))
|
||||||
@click="undoChanges"
|
@click="undoChanges"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
|
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
|
||||||
{{ $t('notification.settings.undo_settings') }}
|
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -158,19 +156,14 @@ onActivated(() => (busy = false))
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" />
|
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" />
|
||||||
{{ $t('notification.settings.unsubscribe') }}
|
{{ $t('settings.notifications.push_notifications.unsubscribe') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="showWarning" role="alert" aria-labelledby="notifications-title">
|
|
||||||
{{ $t('notification.settings.unsubscribed_with_warning') }}
|
|
||||||
</p>
|
|
||||||
<NotificationEnablePushNotification
|
<NotificationEnablePushNotification
|
||||||
v-else
|
|
||||||
:animate="animateSubscription"
|
:animate="animateSubscription"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
:show-re-auth-message="!showWarning"
|
|
||||||
@hide="hideNotification"
|
@hide="hideNotification"
|
||||||
@subscribe="doSubscribe"
|
@subscribe="doSubscribe"
|
||||||
>
|
>
|
||||||
|
|
@ -185,15 +178,16 @@ onActivated(() => (busy = false))
|
||||||
</NotificationEnablePushNotification>
|
</NotificationEnablePushNotification>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<p v-else role="alert" aria-labelledby="notifications-unsupported">
|
<div v-else px6 pb4 role="alert" aria-labelledby="n-unsupported">
|
||||||
{{ $t('notification.settings.unsupported') }}
|
<p id="n-unsupported">
|
||||||
</p>
|
{{ $t('settings.notifications.push_notifications.unsupported') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<NotificationEnablePushNotification
|
<NotificationEnablePushNotification
|
||||||
v-if="showWarning"
|
v-if="showWarning && !show"
|
||||||
show-re-auth-message
|
closeable-header
|
||||||
with-header
|
|
||||||
px5
|
px5
|
||||||
py4
|
py4
|
||||||
:animate="animateSubscription"
|
:animate="animateSubscription"
|
||||||
|
|
@ -210,5 +204,5 @@ onActivated(() => (busy = false))
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
</NotificationEnablePushNotification>
|
</NotificationEnablePushNotification>
|
||||||
</div>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ const { modelValue } = defineModel<{
|
||||||
<head id="notification-failed" flex justify-between>
|
<head id="notification-failed" flex justify-between>
|
||||||
<div flex items-center gap-x-2 font-bold>
|
<div flex items-center gap-x-2 font-bold>
|
||||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||||
<p>{{ title ?? $t('notification.settings.subscription_error.title') }}</p>
|
<p>{{ title ?? $t('settings.notifications.push_notifications.subscription_error.title') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<CommonTooltip placement="bottom" :content="$t('notification.settings.subscription_error.clear_error')">
|
<CommonTooltip placement="bottom" :content="$t('settings.notifications.push_notifications.subscription_error.clear_error')">
|
||||||
<button
|
<button
|
||||||
flex rounded-4 p1
|
flex rounded-4 p1
|
||||||
hover:bg-active cursor-pointer transition-100
|
hover:bg-active cursor-pointer transition-100
|
||||||
:aria-label="$t('notification.settings.subscription_error.clear_error')"
|
:aria-label="$t('settings.notifications.push_notifications.subscription_error.clear_error')"
|
||||||
@click="modelValue = false"
|
@click="modelValue = false"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" w-1.75em h-1.75em i-ri:close-line />
|
<span aria-hidden="true" w-1.75em h-1.75em i-ri:close-line />
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ const disabledVisual = computed(() => isMastoInitialised.value && isGuest.value)
|
||||||
<button
|
<button
|
||||||
flex="~ gap2 center"
|
flex="~ gap2 center"
|
||||||
w-9 h-9 py2
|
w-9 h-9 py2
|
||||||
xl="w-auto h-auto py-4"
|
xl="w-auto h-auto"
|
||||||
rounded-full
|
rounded-3
|
||||||
cursor-pointer disabled:pointer-events-none
|
cursor-pointer disabled:pointer-events-none
|
||||||
text-primary font-bold
|
text-primary
|
||||||
border-1 border-primary
|
border-1 border-primary
|
||||||
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
|
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { statusVisibilities } from '~/composables/masto/icons'
|
|
||||||
|
|
||||||
const { editing } = defineProps<{
|
const { editing } = defineProps<{
|
||||||
editing?: boolean
|
editing?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const {
|
||||||
placeholder,
|
placeholder,
|
||||||
dialogLabelledBy,
|
dialogLabelledBy,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
draftKey: string
|
draftKey?: string
|
||||||
initial?: () => Draft
|
initial?: () => Draft
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
inReplyToId?: string
|
inReplyToId?: string
|
||||||
|
|
@ -38,7 +38,10 @@ const shouldExpanded = $computed(() => _expanded || isExpanded || !isEmpty)
|
||||||
const { editor } = useTiptap({
|
const { editor } = useTiptap({
|
||||||
content: computed({
|
content: computed({
|
||||||
get: () => draft.params.status,
|
get: () => draft.params.status,
|
||||||
set: newVal => draft.params.status = newVal,
|
set: (newVal) => {
|
||||||
|
draft.params.status = newVal
|
||||||
|
draft.lastUpdated = Date.now()
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||||
autofocus: shouldExpanded,
|
autofocus: shouldExpanded,
|
||||||
|
|
@ -194,7 +197,7 @@ defineExpose({
|
||||||
|
|
||||||
<div flex gap-3 flex-1>
|
<div flex gap-3 flex-1>
|
||||||
<NuxtLink :to="getAccountRoute(currentUser.account)">
|
<NuxtLink :to="getAccountRoute(currentUser.account)">
|
||||||
<AccountBigAvatar :account="currentUser.account" />
|
<AccountBigAvatar :account="currentUser.account" square />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -266,7 +269,7 @@ defineExpose({
|
||||||
<PublishAttachment
|
<PublishAttachment
|
||||||
v-for="(att, idx) in draft.attachments" :key="att.id"
|
v-for="(att, idx) in draft.attachments" :key="att.id"
|
||||||
:attachment="att"
|
:attachment="att"
|
||||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : null)"
|
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
||||||
@remove="removeAttachment(idx)"
|
@remove="removeAttachment(idx)"
|
||||||
@set-description="setDescription(att, $event)"
|
@set-description="setDescription(att, $event)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -343,7 +346,7 @@ defineExpose({
|
||||||
</PublishVisibilityPicker>
|
</PublishVisibilityPicker>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
btn-solid rounded-full text-sm w-full md:w-fit
|
btn-solid rounded-3 text-sm w-full md:w-fit
|
||||||
:disabled="isEmpty || isUploading || (draft.attachments.length === 0 && !draft.params.status)"
|
:disabled="isEmpty || isUploading || (draft.attachments.length === 0 && !draft.params.status)"
|
||||||
@click="publish"
|
@click="publish"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
59
components/publish/PublishWidgetFull.client.vue
Normal file
59
components/publish/PublishWidgetFull.client.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { formatTimeAgo } from '@vueuse/core'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
let draftKey = $ref('home')
|
||||||
|
|
||||||
|
const draftKeys = $computed(() => Object.keys(currentUserDrafts.value))
|
||||||
|
const nonEmptyDrafts = $computed(() => draftKeys
|
||||||
|
.filter(i => i !== draftKey && !isEmptyDraft(currentUserDrafts.value[i]))
|
||||||
|
.map(i => [i, currentUserDrafts.value[i]] as const),
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
draftKey = route.query.draft?.toString() || 'home'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
clearEmptyDrafts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex="~ col" pt-6 h-screen>
|
||||||
|
<div text-right h-8>
|
||||||
|
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
||||||
|
<button btn-text flex="inline center">
|
||||||
|
Drafts ({{ nonEmptyDrafts.length }}) <div i-ri:arrow-down-s-line />
|
||||||
|
</button>
|
||||||
|
<template #popper="{ hide }">
|
||||||
|
<div flex="~ col">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="[key, draft] of nonEmptyDrafts" :key="key"
|
||||||
|
border="b base" text-left py2 px4 hover:bg-active
|
||||||
|
:replace="true"
|
||||||
|
:to="`/compose?draft=${encodeURIComponent(key)}`"
|
||||||
|
@click="hide()"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div flex="~ gap-1" items-center>
|
||||||
|
Draft <code>{{ key }}</code>
|
||||||
|
<span v-if="draft.lastUpdated" text-secondary text-sm>
|
||||||
|
· {{ formatTimeAgo(new Date(draft.lastUpdated)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div text-secondary>
|
||||||
|
{{ htmlToText(draft.params.status).slice(0, 50) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PublishWidget :key="draftKey" expanded class="min-h-100!" :draft-key="draftKey" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{ hashtag: any }>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div flex flex-row items-center gap2>
|
|
||||||
<div w-12 h-12 rounded-full bg-active flex place-items-center place-content-center>
|
|
||||||
<div i-ri:hashtag text-secondary text-lg />
|
|
||||||
</div>
|
|
||||||
<div flex flex-col>
|
|
||||||
<span>
|
|
||||||
{{ hashtag.name }}
|
|
||||||
</span>
|
|
||||||
<span text-xs text-secondary>
|
|
||||||
{{ hashtag.following ? 'Following' : 'Not Following' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
20
components/search/SearchAccountInfo.vue
Normal file
20
components/search/SearchAccountInfo.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Account } from 'masto'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
account: Account
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button flex gap-2 items-center>
|
||||||
|
<AccountAvatar w-10 h-10 :account="account" shrink-0 />
|
||||||
|
<div flex="~ col gap1" shrink h-full overflow-hidden leading-none>
|
||||||
|
<div flex="~" gap-2>
|
||||||
|
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all text-base />
|
||||||
|
<AccountBotIndicator v-if="account.bot" />
|
||||||
|
</div>
|
||||||
|
<AccountHandle text-sm :account="account" text-secondary-light />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
26
components/search/SearchHashtagInfo.vue
Normal file
26
components/search/SearchHashtagInfo.vue
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tag } from 'masto'
|
||||||
|
|
||||||
|
const { hashtag } = defineProps<{ hashtag: Tag }>()
|
||||||
|
|
||||||
|
const totalTrend = $computed(() =>
|
||||||
|
hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex flex-row items-center gap2 relative>
|
||||||
|
<div w-10 h-10 flex-none rounded-full bg-active flex place-items-center place-content-center>
|
||||||
|
<div i-ri:hashtag text-secondary text-lg />
|
||||||
|
</div>
|
||||||
|
<div flex flex-col>
|
||||||
|
<span>
|
||||||
|
{{ hashtag.name }}
|
||||||
|
</span>
|
||||||
|
<CommonTrending :history="hashtag.history" text-xs text-secondary truncate />
|
||||||
|
</div>
|
||||||
|
<div v-if="totalTrend" absolute left-15 right-0 top-0 bottom-4 op35 flex place-items-center place-content-center ml-auto>
|
||||||
|
<CommonTrendingCharts :history="hashtag.history" text-xs text-secondary width="150" height="20" h-full w-full />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SearchResult } from './types'
|
import type { SearchResult } from './types'
|
||||||
defineProps<{ result: SearchResult; active: boolean }>()
|
|
||||||
|
defineProps<{
|
||||||
|
result: SearchResult
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const onActivate = () => {
|
const onActivate = () => {
|
||||||
(document.activeElement as HTMLElement).blur()
|
(document.activeElement as HTMLElement).blur()
|
||||||
|
|
@ -8,12 +12,20 @@ const onActivate = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonScrollIntoView as="RouterLink" :active="active" :to="result.to" py2 block px2 :aria-selected="active" :class="{ 'bg-active': active }" hover:bg-active @click="() => onActivate()">
|
<CommonScrollIntoView
|
||||||
|
as="RouterLink"
|
||||||
|
hover:bg-active
|
||||||
|
:active="active"
|
||||||
|
:to="result.to" py2 block px2
|
||||||
|
:aria-selected="active"
|
||||||
|
:class="{ 'bg-active': active }"
|
||||||
|
@click="() => onActivate()"
|
||||||
|
>
|
||||||
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.hashtag" />
|
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.hashtag" />
|
||||||
<AccountInfo v-else-if="result.type === 'account'" :account="result.account" />
|
<SearchAccountInfo v-else-if="result.type === 'account' && result.account" :account="result.account" />
|
||||||
<StatusCard v-else-if="result.type === 'status'" :status="result.status" :actions="false" :show-reply-to="false" />
|
<StatusCard v-else-if="result.type === 'status' && result.status" :status="result.status" :actions="false" :show-reply-to="false" />
|
||||||
<div v-else-if="result.type === 'action'" text-center>
|
<!-- <div v-else-if="result.type === 'action'" text-center>
|
||||||
{{ result.action!.label }}
|
{{ result.action!.label }}
|
||||||
</div>
|
</div> -->
|
||||||
</CommonScrollIntoView>
|
</CommonScrollIntoView>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { AccountResult, HashTagResult, StatusResult } from './types'
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const { accounts, hashtags, loading, statuses } = useSearch(query)
|
const { accounts, hashtags, loading, statuses } = useSearch(query)
|
||||||
const index = ref(0)
|
const index = ref(0)
|
||||||
|
|
@ -13,9 +15,24 @@ const results = computed(() => {
|
||||||
return []
|
return []
|
||||||
|
|
||||||
const results = [
|
const results = [
|
||||||
...hashtags.value.slice(0, 3).map(hashtag => ({ type: 'hashtag', hashtag, to: getTagRoute(hashtag.name) })),
|
...hashtags.value.slice(0, 3).map<HashTagResult>(hashtag => ({
|
||||||
...accounts.value.map(account => ({ type: 'account', account, to: getAccountRoute(account) })),
|
type: 'hashtag',
|
||||||
...statuses.value.map(status => ({ type: 'status', status, to: getStatusRoute(status) })),
|
id: hashtag.id,
|
||||||
|
hashtag,
|
||||||
|
to: getTagRoute(hashtag.name),
|
||||||
|
})),
|
||||||
|
...accounts.value.map<AccountResult>(account => ({
|
||||||
|
type: 'account',
|
||||||
|
id: account.id,
|
||||||
|
account,
|
||||||
|
to: getAccountRoute(account),
|
||||||
|
})),
|
||||||
|
...statuses.value.map<StatusResult>(status => ({
|
||||||
|
type: 'status',
|
||||||
|
id: status.id,
|
||||||
|
status,
|
||||||
|
to: getStatusRoute(status),
|
||||||
|
})),
|
||||||
|
|
||||||
// Disable until search page is implemented
|
// Disable until search page is implemented
|
||||||
// {
|
// {
|
||||||
|
|
@ -52,15 +69,14 @@ const activate = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" relative px4 py2 group>
|
<div ref="el" relative group>
|
||||||
<div bg-base border="~ base" h10 rounded-full flex="~ row" items-center relative focus-within:box-shadow-outline>
|
<div bg-base border="~ base" h10 px-4 rounded-3 flex="~ row" items-center relative focus-within:box-shadow-outline gap-3>
|
||||||
<div i-ri:search-2-line mx4 absolute pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
<div i-ri:search-2-line pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
h-full
|
h-full
|
||||||
ps-10
|
rounded-3
|
||||||
rounded-full
|
|
||||||
w-full
|
w-full
|
||||||
bg-transparent
|
bg-transparent
|
||||||
outline="focus:none"
|
outline="focus:none"
|
||||||
|
|
@ -74,13 +90,18 @@ const activate = () => {
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div p4 left-0 top-10 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
|
<div left-0 top-12 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
|
||||||
<div w-full bg-base border="~ base" rounded max-h-100 overflow-auto py2>
|
<div w-full bg-base border="~ base" rounded-3 max-h-100 overflow-auto py2>
|
||||||
<span v-if="query.length === 0" block text-center text-sm text-secondary>
|
<span v-if="query.length === 0" block text-center text-sm text-secondary>
|
||||||
{{ t('search.search_desc') }}
|
{{ t('search.search_desc') }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<SearchResult v-for="(result, i) in results" :key="result.to" :active="index === parseInt(i.toString())" :result="result" :tabindex="focused ? 0 : -1" />
|
<SearchResult
|
||||||
|
v-for="(result, i) in results" :key="result.id"
|
||||||
|
:active="index === parseInt(i.toString())"
|
||||||
|
:result="result"
|
||||||
|
:tabindex="focused ? 0 : -1"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<SearchResultSkeleton />
|
<SearchResultSkeleton />
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import type { Account, Status } from 'masto'
|
import type { Account, Status } from 'masto'
|
||||||
|
import type { RouteLocation } from 'vue-router'
|
||||||
|
|
||||||
export interface SearchResult {
|
export type BuildResult<K extends keyof any, T> = {
|
||||||
type: 'account' | 'hashtag' | 'action' | 'status'
|
[P in K]: T
|
||||||
to: string
|
} & {
|
||||||
label?: string
|
id: string
|
||||||
account?: Account
|
type: K
|
||||||
status?: Status
|
to: RouteLocation & {
|
||||||
hashtag?: any
|
href: string
|
||||||
action?: {
|
|
||||||
label: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export type HashTagResult = BuildResult<'hashtag', any>
|
||||||
|
export type AccountResult = BuildResult<'account', Account>
|
||||||
|
export type StatusResult = BuildResult<'status', Status>
|
||||||
|
|
||||||
|
export type SearchResult = HashTagResult | AccountResult | StatusResult
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ const props = defineProps<{
|
||||||
icon?: string
|
icon?: string
|
||||||
to?: string | Record<string, string>
|
to?: string | Record<string, string>
|
||||||
command?: boolean
|
command?: boolean
|
||||||
|
disabled?: boolean
|
||||||
external?: true
|
external?: true
|
||||||
|
large?: true
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -33,10 +35,13 @@ useCommand({
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
:disabled="disabled"
|
||||||
:to="to"
|
:to="to"
|
||||||
:external="external"
|
:external="external"
|
||||||
exact-active-class="text-primary"
|
exact-active-class="text-primary"
|
||||||
|
:class="disabled ? 'op25 pointer-events-none ' : ''"
|
||||||
block w-full group focus:outline-none
|
block w-full group focus:outline-none
|
||||||
|
:tabindex="disabled ? -1 : null"
|
||||||
@click="to ? $scrollToTop() : undefined"
|
@click="to ? $scrollToTop() : undefined"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -51,7 +56,10 @@ useCommand({
|
||||||
:class="$slots.description ? 'w-12 h-12' : ''"
|
:class="$slots.description ? 'w-12 h-12' : ''"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
<div v-if="icon" :class="icon" md:text-size-inherit text-xl />
|
<div
|
||||||
|
v-if="icon"
|
||||||
|
:class="[icon, large ? 'text-xl mr-1' : 'text-xl md:text-size-inherit']"
|
||||||
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div space-y-1>
|
<div space-y-1>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UpdateCredentialsParams } from 'masto'
|
import type { UpdateCredentialsParams } from 'masto'
|
||||||
import { accountFieldIcons, getAccountFieldIcon } from '~/composables/masto/icons'
|
|
||||||
|
|
||||||
const { form } = defineModel<{
|
const { form } = defineModel<{
|
||||||
form: {
|
form: {
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ const { account, link = true } = defineProps<{
|
||||||
flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
|
flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
|
||||||
text-link-rounded
|
text-link-rounded
|
||||||
>
|
>
|
||||||
<ContentRich
|
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||||
font-bold line-clamp-1 ws-pre-wrap break-all
|
|
||||||
:content="getDisplayName(account, { rich: true })"
|
|
||||||
:emojis="account.emojis"
|
|
||||||
/>
|
|
||||||
<AccountHandle :account="account" />
|
<AccountHandle :account="account" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const reply = () => {
|
||||||
color="text-green" hover="text-green" group-hover="bg-green/10"
|
color="text-green" hover="text-green" group-hover="bg-green/10"
|
||||||
icon="i-ri:repeat-line"
|
icon="i-ri:repeat-line"
|
||||||
active-icon="i-ri:repeat-fill"
|
active-icon="i-ri:repeat-fill"
|
||||||
:active="status.reblogged"
|
:active="!!status.reblogged"
|
||||||
:disabled="isLoading.reblogged"
|
:disabled="isLoading.reblogged"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleReblog()"
|
@click="toggleReblog()"
|
||||||
|
|
@ -88,7 +88,7 @@ const reply = () => {
|
||||||
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
||||||
icon="i-ri:heart-3-line"
|
icon="i-ri:heart-3-line"
|
||||||
active-icon="i-ri:heart-3-fill"
|
active-icon="i-ri:heart-3-fill"
|
||||||
:active="status.favourited"
|
:active="!!status.favourited"
|
||||||
:disabled="isLoading.favourited"
|
:disabled="isLoading.favourited"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleFavourite()"
|
@click="toggleFavourite()"
|
||||||
|
|
@ -111,7 +111,7 @@ const reply = () => {
|
||||||
color="text-yellow" hover="text-yellow" group-hover="bg-yellow/10"
|
color="text-yellow" hover="text-yellow" group-hover="bg-yellow/10"
|
||||||
icon="i-ri:bookmark-line"
|
icon="i-ri:bookmark-line"
|
||||||
active-icon="i-ri:bookmark-fill"
|
active-icon="i-ri:bookmark-fill"
|
||||||
:active="status.bookmarked"
|
:active="!!status.bookmarked"
|
||||||
:disabled="isLoading.bookmarked"
|
:disabled="isLoading.bookmarked"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBookmark()"
|
@click="toggleBookmark()"
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ async function editStatus() {
|
||||||
|
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div flex="~ col">
|
<div flex="~ col">
|
||||||
<template v-if="isZenMode">
|
<template v-if="userSettings.zenMode">
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
:text="$t('action.reply')"
|
:text="$t('action.reply')"
|
||||||
icon="i-ri:chat-3-line"
|
icon="i-ri:chat-3-line"
|
||||||
|
|
@ -186,9 +186,8 @@ async function editStatus() {
|
||||||
@click="toggleMute()"
|
@click="toggleMute()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NuxtLink :to="status.url" external target="_blank">
|
<NuxtLink v-if="status.url" :to="status.url" external target="_blank">
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-if="status.url"
|
|
||||||
:text="$t('menu.open_in_original_site')"
|
:text="$t('menu.open_in_original_site')"
|
||||||
icon="i-ri:arrow-right-up-line"
|
icon="i-ri:arrow-right-up-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,12 @@ const aspectRatio = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const objectPosition = computed(() => {
|
const objectPosition = computed(() => {
|
||||||
return [attachment.meta?.focus?.x, attachment.meta?.focus?.y]
|
const focusX = attachment.meta?.focus?.x || 0
|
||||||
.map(v => v ? `${v * 100}%` : '50%')
|
const focusY = attachment.meta?.focus?.y || 0
|
||||||
.join(' ')
|
const x = ((focusX / 2) + 0.5) * 100
|
||||||
|
const y = ((focusY / -2) + 0.5) * 100
|
||||||
|
|
||||||
|
return `${x}% ${y}%`
|
||||||
})
|
})
|
||||||
|
|
||||||
const typeExtsMap = {
|
const typeExtsMap = {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from 'masto'
|
import type { Status, StatusEdit } from 'masto'
|
||||||
|
|
||||||
const { status, withAction = true } = defineProps<{
|
const { status, withAction = true } = defineProps<{
|
||||||
status: Status
|
status: Status | StatusEdit
|
||||||
withAction?: boolean
|
withAction?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { translation } = useTranslation(status)
|
const { translation } = useTranslation(status)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ const { translation } = useTranslation(status)
|
||||||
class="line-compact"
|
class="line-compact"
|
||||||
:content="status.content"
|
:content="status.content"
|
||||||
:emojis="status.emojis"
|
:emojis="status.emojis"
|
||||||
:lang="status.language"
|
:lang="'language' in status && status.language"
|
||||||
/>
|
/>
|
||||||
<div v-else />
|
<div v-else />
|
||||||
<template v-if="translation.visible">
|
<template v-if="translation.visible">
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,14 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
ref="el"
|
ref="el"
|
||||||
relative flex flex-col gap-1 pl-3 pr-4 pt-1
|
relative flex flex-col gap-1 pl-3 pr-4 pt-1
|
||||||
class="pb-1.5"
|
class="pb-1.5"
|
||||||
:class="{ 'hover:bg-active': hover, 'border-t border-base': newer && !directReply }"
|
:class="{ 'hover:bg-active': hover }"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
focus:outline-none focus-visible:ring="2 primary"
|
focus:outline-none focus-visible:ring="2 primary"
|
||||||
:lang="status.language ?? undefined"
|
:lang="status.language ?? undefined"
|
||||||
@click="onclick"
|
@click="onclick"
|
||||||
@keydown.enter="onclick"
|
@keydown.enter="onclick"
|
||||||
>
|
>
|
||||||
|
<div v-if="newer && !directReply" w-auto h-1px bg-border />
|
||||||
<div flex justify-between>
|
<div flex justify-between>
|
||||||
<slot name="meta">
|
<slot name="meta">
|
||||||
<div v-if="rebloggedBy && !collapseRebloggedBy" relative text-secondary ws-nowrap flex="~" items-center pt1 pb0.5 px-1px bg-base>
|
<div v-if="rebloggedBy && !collapseRebloggedBy" relative text-secondary ws-nowrap flex="~" items-center pt1 pb0.5 px-1px bg-base>
|
||||||
|
|
@ -112,7 +113,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
</div>
|
</div>
|
||||||
<div v-else />
|
<div v-else />
|
||||||
</slot>
|
</slot>
|
||||||
<StatusReplyingTo v-if="!directReply && !collapseReplyingTo" :status="status" :simplified="simplifyReplyingTo" :class="faded ? 'text-secondary-light' : ''" pt1 />
|
<StatusReplyingTo v-if="!directReply && !collapseReplyingTo" :status="status" :simplified="!!simplifyReplyingTo" :class="faded ? 'text-secondary-light' : ''" pt1 />
|
||||||
</div>
|
</div>
|
||||||
<div flex gap-3 :class="{ 'text-secondary': faded }">
|
<div flex gap-3 :class="{ 'text-secondary': faded }">
|
||||||
<div relative>
|
<div relative>
|
||||||
|
|
@ -125,7 +126,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
<div v-if="connectReply" w-full h-full flex justify-center>
|
<div v-if="connectReply" w-full h-full flex justify-center>
|
||||||
<div h-full class="w-2.5px" bg-border />
|
<div class="w-2.5px" bg-primary-light />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div flex="~ col 1" min-w-0>
|
<div flex="~ col 1" min-w-0>
|
||||||
|
|
@ -137,7 +138,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
<StatusReplyingTo :collapsed="true" :status="status" :class="faded ? 'text-secondary-light' : ''" />
|
<StatusReplyingTo :collapsed="true" :status="status" :class="faded ? 'text-secondary-light' : ''" />
|
||||||
</div>
|
</div>
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
<div v-if="!isZenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
|
<div v-if="!userSettings.zenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
|
||||||
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
||||||
<div flex>
|
<div flex>
|
||||||
<CommonTooltip :content="createdAt">
|
<CommonTooltip :content="createdAt">
|
||||||
|
|
@ -154,7 +155,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
</div>
|
</div>
|
||||||
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
||||||
<div>
|
<div>
|
||||||
<StatusActions v-if="(actions !== false && !isZenMode)" :status="status" />
|
<StatusActions v-if="(actions !== false && !userSettings.zenMode)" :status="status" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from 'masto'
|
import type { Status } from 'masto'
|
||||||
import { statusVisibilities } from '~/composables/masto/icons'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
status: Status
|
status: Status
|
||||||
|
|
@ -55,7 +54,12 @@ const isDM = $computed(() => status.visibility === 'direct')
|
||||||
<div v-if="status.application?.name">
|
<div v-if="status.application?.name">
|
||||||
·
|
·
|
||||||
</div>
|
</div>
|
||||||
<div v-if="status.application?.name">
|
<div v-if="status.application?.website && status.application.name">
|
||||||
|
<NuxtLink :to="status.application.website">
|
||||||
|
{{ status.application.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="status.application?.name">
|
||||||
{{ status.application?.name }}
|
{{ status.application?.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from 'masto'
|
import type { Status, StatusEdit } from 'masto'
|
||||||
|
|
||||||
const { status } = defineProps<{
|
const { status } = defineProps<{
|
||||||
status: Status
|
status: Status | StatusEdit
|
||||||
fullSize?: boolean
|
fullSize?: boolean
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const isSquare = $computed(() => (
|
||||||
))
|
))
|
||||||
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
||||||
|
|
||||||
const gitHubCards = $(computedEager(() => useFeatureFlags().experimentalGitHubCards))
|
const gitHubCards = $(useFeatureFlag('experimentalGitHubCards'))
|
||||||
|
|
||||||
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
||||||
const cardTypeIconMap: Record<CardType, string> = {
|
const cardTypeIconMap: Record<CardType, string> = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Card } from 'masto'
|
import type { Card } from 'masto'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
card: Card
|
card: Card
|
||||||
/** When it is root card in the list, not appear as a child card */
|
/** When it is root card in the list, not appear as a child card */
|
||||||
root?: boolean
|
root?: boolean
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ const account = isSelf ? computed(() => status.account) : useAccountById(status.
|
||||||
<AccountInlineInfo v-else :account="account" :link="false" mx-0.5 />
|
<AccountInlineInfo v-else :account="account" :link="false" mx-0.5 />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<div i-ph:chats-fill text-primary text-lg />
|
<div i-ri:question-answer-line text-secondary-light text-lg />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,7 @@ const { edit } = defineProps<{
|
||||||
{{ edit.spoilerText }}
|
{{ edit.spoilerText }}
|
||||||
</template>
|
</template>
|
||||||
<StatusBody :status="edit" />
|
<StatusBody :status="edit" />
|
||||||
<StatusMedia
|
<StatusMedia v-if="edit.mediaAttachments.length" :status="edit" />
|
||||||
v-if="edit.mediaAttachments.length"
|
|
||||||
:status="edit"
|
|
||||||
/>
|
|
||||||
</StatusSpoiler>
|
</StatusSpoiler>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from 'masto'
|
|
||||||
const paginator = useMasto().timelines.iterateHome()
|
const paginator = useMasto().timelines.iterateHome()
|
||||||
const stream = useMasto().stream.streamUser()
|
const stream = useMasto().stream.streamUser()
|
||||||
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
|
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,26 @@
|
||||||
// @ts-expect-error missing types
|
// @ts-expect-error missing types
|
||||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
import type { FilterContext, Paginator, Status, WsEvents } from 'masto'
|
import type { Account, FilterContext, Paginator, Status, WsEvents } from 'masto'
|
||||||
|
|
||||||
const { paginator, stream } = defineProps<{
|
const { paginator, stream, account } = defineProps<{
|
||||||
paginator: Paginator<any, Status[]>
|
paginator: Paginator<any, Status[]>
|
||||||
stream?: Promise<WsEvents>
|
stream?: Promise<WsEvents>
|
||||||
context?: FilterContext
|
context?: FilterContext
|
||||||
|
account?: Account
|
||||||
preprocess?: (items: any[]) => any[]
|
preprocess?: (items: any[]) => any[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { formatNumber } = useHumanReadableNumber()
|
const { formatNumber } = useHumanReadableNumber()
|
||||||
const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirtualScroll))
|
const virtualScroller = $(useFeatureFlag('experimentalVirtualScroll'))
|
||||||
|
|
||||||
|
const showOriginSite = $computed(() =>
|
||||||
|
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller" :is-account-timeline="context === 'account'">
|
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller">
|
||||||
<template #updater="{ number, update }">
|
<template #updater="{ number, update }">
|
||||||
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update">
|
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update">
|
||||||
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
||||||
|
|
@ -32,5 +37,18 @@ const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirt
|
||||||
<StatusCard :status="item" :context="context" :older="older" :newer="newer" />
|
<StatusCard :status="item" :context="context" :older="older" :newer="newer" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="context === 'account' && showOriginSite" #done>
|
||||||
|
<div p5 text-secondary text-center flex flex-col items-center gap1>
|
||||||
|
<span italic>{{ $t('timeline.view_older_posts') }}</span>
|
||||||
|
<a
|
||||||
|
:href="account!.url" target="_blank"
|
||||||
|
flex="~ gap-1" items-center text-primary
|
||||||
|
hover="underline text-primary-active"
|
||||||
|
>
|
||||||
|
<div i-ri:external-link-fill />
|
||||||
|
{{ $t('menu.open_in_original_site') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</CommonPaginator>
|
</CommonPaginator>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
67
components/tiptap/TiptapHashtagList.vue
Normal file
67
components/tiptap/TiptapHashtagList.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tag } from 'masto'
|
||||||
|
import CommonScrollIntoView from '../common/CommonScrollIntoView.vue'
|
||||||
|
|
||||||
|
const { items, command } = defineProps<{
|
||||||
|
items: Tag[]
|
||||||
|
command: Function
|
||||||
|
isPending?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
let selectedIndex = $ref(0)
|
||||||
|
|
||||||
|
watch(items, () => {
|
||||||
|
selectedIndex = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else if (event.key === 'ArrowDown') {
|
||||||
|
selectedIndex = (selectedIndex + 1) % items.length
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
else if (event.key === 'Enter') {
|
||||||
|
selectItem(selectedIndex)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(index: number) {
|
||||||
|
const item = items[index]
|
||||||
|
if (item)
|
||||||
|
command({ id: item.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
onKeyDown,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100>
|
||||||
|
<template v-if="isPending">
|
||||||
|
<div flex gap-1 items-center p2 animate-pulse>
|
||||||
|
<div i-ri:loader-2-line animate-spin />
|
||||||
|
<span>Fetching...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="items.length">
|
||||||
|
<CommonScrollIntoView
|
||||||
|
v-for="(item, index) in items" :key="index"
|
||||||
|
:active="index === selectedIndex"
|
||||||
|
as="button"
|
||||||
|
:class="index === selectedIndex ? 'bg-active' : 'text-secondary'"
|
||||||
|
block m0 w-full text-left px2 py1
|
||||||
|
@click="selectItem(index)"
|
||||||
|
>
|
||||||
|
<SearchHashtagInfo :hashtag="item" />
|
||||||
|
</CommonScrollIntoView>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-else />
|
||||||
|
</template>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<VDropdown :distance="0" placement="top-start">
|
<VDropdown :distance="0" placement="top-start">
|
||||||
<button btn-action-icon :aria-label="$t('action.switch_account')">
|
<button btn-action-icon :aria-label="$t('action.switch_account')">
|
||||||
<!-- TODO -->
|
|
||||||
<div :class="{ 'hidden xl:block': !isGuest }" i-ri:more-2-line />
|
<div :class="{ 'hidden xl:block': !isGuest }" i-ri:more-2-line />
|
||||||
<AccountAvatar v-if="!isGuest" xl:hidden :account="currentUser.account" w-9 h-9 />
|
<AccountAvatar v-if="checkAuth(currentUser)" xl:hidden :account="currentUser.account" w-9 h-9 square />
|
||||||
|
<!-- TODO -->
|
||||||
<span v-else>TODO: Guest</span>
|
<span v-else>TODO: Guest</span>
|
||||||
</button>
|
</button>
|
||||||
<template #popper="{ hide }">
|
<template #popper="{ hide }">
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const masto = useMasto()
|
||||||
hover="filter-none op100"
|
hover="filter-none op100"
|
||||||
@click="switchUser(user, masto)"
|
@click="switchUser(user, masto)"
|
||||||
>
|
>
|
||||||
<AccountAvatar v-if="!user.guest" w-13 h-13 :account="user.account" />
|
<AccountAvatar v-if="checkAuth(user)" w-13 h-13 :account="user.account" square />
|
||||||
<div v-else bg="gray/40" rounded-full w-13 h-13 flex shrink-0 items-center justify-center text-5>
|
<div v-else bg="gray/40" rounded-full w-13 h-13 flex shrink-0 items-center justify-center text-5>
|
||||||
G
|
G
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<p text-sm text-secondary>
|
<p text-sm text-secondary>
|
||||||
{{ $t('user.sign_in_desc') }}
|
{{ $t('user.sign_in_desc') }}
|
||||||
</p>
|
</p>
|
||||||
<button btn-solid text-center mt-2 @click="openSigninDialog()">
|
<button btn-solid rounded-3 text-center mt-2 @click="openSigninDialog()">
|
||||||
{{ $t('action.sign_in') }}
|
{{ $t('action.sign_in') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const masto = useMasto()
|
||||||
aria-label="Switch user"
|
aria-label="Switch user"
|
||||||
@click="switchUser(user, masto)"
|
@click="switchUser(user, masto)"
|
||||||
>
|
>
|
||||||
<AccountInfo v-if="!user.guest" :account="user.account" :hover-card="false" />
|
<AccountInfo v-if="checkAuth(user)" :account="user.account" :hover-card="false" square />
|
||||||
<AccountGuest v-else :user="user" />
|
<AccountGuest v-else :user="user" />
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
<div v-if="isSameUser(user, currentUser)" i-ri:check-line text-primary mya text-2xl />
|
<div v-if="isSameUser(user, currentUser)" i-ri:check-line text-primary mya text-2xl />
|
||||||
|
|
|
||||||
|
|
@ -245,20 +245,6 @@ export const provideGlobalCommands = () => {
|
||||||
const masto = useMasto()
|
const masto = useMasto()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
useCommand({
|
|
||||||
scope: 'Actions',
|
|
||||||
|
|
||||||
visible: () => !isGuest.value,
|
|
||||||
|
|
||||||
name: () => t('action.compose'),
|
|
||||||
icon: 'i-ri:quill-pen-line',
|
|
||||||
description: () => t('command.compose_desc'),
|
|
||||||
|
|
||||||
onActivate() {
|
|
||||||
openPublishDialog()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useCommand({
|
useCommand({
|
||||||
scope: 'Navigation',
|
scope: 'Navigation',
|
||||||
|
|
||||||
|
|
@ -285,10 +271,10 @@ export const provideGlobalCommands = () => {
|
||||||
scope: 'Preferences',
|
scope: 'Preferences',
|
||||||
|
|
||||||
name: () => t('command.toggle_zen_mode'),
|
name: () => t('command.toggle_zen_mode'),
|
||||||
icon: () => isZenMode.value ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line',
|
icon: () => userSettings.value.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line',
|
||||||
|
|
||||||
onActivate() {
|
onActivate() {
|
||||||
toggleZenMode()
|
userSettings.value.zenMode = !userSettings.value.zenMode
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @unimport-disable
|
// @unimport-disable
|
||||||
import type { Emoji } from 'masto'
|
import type { Emoji } from 'masto'
|
||||||
import type { Node } from 'ultrahtml'
|
import type { Node } from 'ultrahtml'
|
||||||
import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml'
|
import { ELEMENT_NODE, TEXT_NODE, h, parse, render } from 'ultrahtml'
|
||||||
import { findAndReplaceEmojisInText } from '@iconify/utils'
|
import { findAndReplaceEmojisInText } from '@iconify/utils'
|
||||||
import { emojiRegEx, getEmojiAttributes } from '../config/emojis'
|
import { emojiRegEx, getEmojiAttributes } from '../config/emojis'
|
||||||
|
|
||||||
|
|
@ -19,53 +19,43 @@ export function decodeHtml(text: string) {
|
||||||
* with interop of custom emojis and inline Markdown syntax
|
* with interop of custom emojis and inline Markdown syntax
|
||||||
*/
|
*/
|
||||||
export function parseMastodonHTML(html: string, customEmojis: Record<string, Emoji> = {}, markdown = true, forTiptap = false) {
|
export function parseMastodonHTML(html: string, customEmojis: Record<string, Emoji> = {}, markdown = true, forTiptap = false) {
|
||||||
// unicode emojis to images, but only if not converting HTML for Tiptap
|
|
||||||
let processed = forTiptap ? html : replaceUnicodeEmoji(html)
|
|
||||||
|
|
||||||
// custom emojis
|
|
||||||
processed = processed.replace(/:([\w-]+?):/g, (_, name) => {
|
|
||||||
const emoji = customEmojis[name]
|
|
||||||
if (emoji)
|
|
||||||
return `<img src="${emoji.url}" alt=":${name}:" class="custom-emoji" data-emoji-id="${name}" />`
|
|
||||||
return `:${name}:`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (markdown) {
|
if (markdown) {
|
||||||
// handle code blocks
|
// Handle code blocks
|
||||||
processed = processed
|
html = html
|
||||||
.replace(/>(```|~~~)(\w*)([\s\S]+?)\1/g, (_1, _2, lang, raw) => {
|
.replace(/>(```|~~~)(\w*)([\s\S]+?)\1/g, (_1, _2, lang, raw) => {
|
||||||
const code = htmlToText(raw)
|
const code = htmlToText(raw)
|
||||||
const classes = lang ? ` class="language-${lang}"` : ''
|
const classes = lang ? ` class="language-${lang}"` : ''
|
||||||
return `><pre><code${classes}>${code}</code></pre>`
|
return `><pre><code${classes}>${code}</code></pre>`
|
||||||
})
|
})
|
||||||
|
|
||||||
walkSync(parse(processed), (node) => {
|
|
||||||
if (node.type !== TEXT_NODE)
|
|
||||||
return
|
|
||||||
const replacements = [
|
|
||||||
[/\*\*\*(.*?)\*\*\*/g, '<b><em>$1</em></b>'],
|
|
||||||
[/\*\*(.*?)\*\*/g, '<b>$1</b>'],
|
|
||||||
[/\*(.*?)\*/g, '<em>$1</em>'],
|
|
||||||
[/~~(.*?)~~/g, '<del>$1</del>'],
|
|
||||||
[/`([^`]+?)`/g, '<code>$1</code>'],
|
|
||||||
] as any
|
|
||||||
|
|
||||||
for (const [re, replacement] of replacements) {
|
|
||||||
for (const match of node.value.matchAll(re)) {
|
|
||||||
if (node.loc) {
|
|
||||||
const start = match.index! + node.loc[0].start
|
|
||||||
const end = start + match[0].length + node.loc[0].start
|
|
||||||
processed = processed.slice(0, start) + match[0].replace(re, replacement) + processed.slice(end)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
processed = processed.replace(match[0], match[0].replace(re, replacement))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parse(processed)
|
// Always sanitize the raw HTML data *after* it has been modified
|
||||||
|
const basicClasses = filterClasses(/^(h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible)$/u)
|
||||||
|
return transformSync(parse(html), [
|
||||||
|
sanitize({
|
||||||
|
// Allow basic elements as seen in https://github.com/mastodon/mastodon/blob/17f79082b098e05b68d6f0d38fabb3ac121879a9/lib/sanitize_ext/sanitize_config.rb
|
||||||
|
br: {},
|
||||||
|
p: {},
|
||||||
|
a: {
|
||||||
|
href: filterHref(),
|
||||||
|
class: basicClasses,
|
||||||
|
rel: set('nofollow noopener noreferrer'),
|
||||||
|
target: set('_blank'),
|
||||||
|
},
|
||||||
|
span: {
|
||||||
|
class: basicClasses,
|
||||||
|
},
|
||||||
|
// Allow elements potentially created for Markdown code blocks above
|
||||||
|
pre: {},
|
||||||
|
code: {
|
||||||
|
class: filterClasses(/^language-\w+$/),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Unicode emojis to images, but only if not converting HTML for Tiptap
|
||||||
|
!forTiptap ? replaceUnicodeEmoji() : noopTransform(),
|
||||||
|
markdown ? formatMarkdown() : noopTransform(),
|
||||||
|
replaceCustomEmoji(customEmojis),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -133,12 +123,210 @@ export function treeToText(input: Node): string {
|
||||||
return pre + body + post
|
return pre + body + post
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// A tree transform function takes an ultrahtml Node object and returns
|
||||||
* Replace unicode emojis with locally hosted images
|
// new content that will replace the given node in the tree.
|
||||||
*/
|
// Returning a null removes the node from the tree.
|
||||||
export function replaceUnicodeEmoji(html: string) {
|
// Strings get converted to text nodes.
|
||||||
return findAndReplaceEmojisInText(emojiRegEx, html, (match) => {
|
// The input node's children have been transformed before the node itself
|
||||||
const attrs = getEmojiAttributes(match)
|
// gets transformed.
|
||||||
return `<img src="${attrs.src}" alt="${attrs.alt}" class="${attrs.class}" />`
|
type Transform = (node: Node) => (Node | string)[] | Node | string | null
|
||||||
}) || html
|
|
||||||
|
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
|
||||||
|
// by running the given chain of transform functions one-by-one.
|
||||||
|
function transformSync(doc: Node, transforms: Transform[]) {
|
||||||
|
function visit(node: Node, transform: Transform, isRoot = false) {
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
const children = [] as (Node | string)[]
|
||||||
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
|
const result = visit(node.children[i], transform)
|
||||||
|
if (Array.isArray(result))
|
||||||
|
children.push(...result)
|
||||||
|
|
||||||
|
else if (result)
|
||||||
|
children.push(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children = children.map((value) => {
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return { type: TEXT_NODE, value, parent: node }
|
||||||
|
value.parent = node
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return isRoot ? node : transform(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transform of transforms)
|
||||||
|
doc = visit(doc, transform, true) as Node
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transformation that does nothing. Useful for conditional transform chains.
|
||||||
|
function noopTransform(): Transform {
|
||||||
|
return node => node
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tree transform for sanitizing elements & their attributes.
|
||||||
|
type AttrSanitizers = Record<string, (value: string | undefined) => string | undefined>
|
||||||
|
function sanitize(allowedElements: Record<string, AttrSanitizers>): Transform {
|
||||||
|
return (node) => {
|
||||||
|
if (node.type !== ELEMENT_NODE)
|
||||||
|
return node
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(allowedElements, node.name))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const attrSanitizers = allowedElements[node.name]
|
||||||
|
const attrs = {} as Record<string, string>
|
||||||
|
for (const [name, func] of Object.entries(attrSanitizers)) {
|
||||||
|
const value = func(node.attributes[name])
|
||||||
|
if (value !== undefined)
|
||||||
|
attrs[name] = value
|
||||||
|
}
|
||||||
|
node.attributes = attrs
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterClasses(allowed: RegExp) {
|
||||||
|
return (c: string | undefined) => {
|
||||||
|
if (!c)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
return c.split(/\s/g).filter(cls => allowed.test(cls)).join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(value: string) {
|
||||||
|
return () => value
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterHref() {
|
||||||
|
const LINK_PROTOCOLS = new Set([
|
||||||
|
'http:',
|
||||||
|
'https:',
|
||||||
|
'dat:',
|
||||||
|
'dweb:',
|
||||||
|
'ipfs:',
|
||||||
|
'ipns:',
|
||||||
|
'ssb:',
|
||||||
|
'gopher:',
|
||||||
|
'xmpp:',
|
||||||
|
'magnet:',
|
||||||
|
'gemini:',
|
||||||
|
])
|
||||||
|
|
||||||
|
return (href: string | undefined) => {
|
||||||
|
if (href === undefined)
|
||||||
|
return undefined
|
||||||
|
|
||||||
|
// Allow relative links
|
||||||
|
if (href.startsWith('/') || href.startsWith('.'))
|
||||||
|
return href
|
||||||
|
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(href)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof TypeError)
|
||||||
|
return undefined
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LINK_PROTOCOLS.has(url.protocol))
|
||||||
|
return url.toString()
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceUnicodeEmoji(): Transform {
|
||||||
|
return (node) => {
|
||||||
|
if (node.type !== TEXT_NODE)
|
||||||
|
return node
|
||||||
|
|
||||||
|
let start = 0
|
||||||
|
|
||||||
|
const matches = [] as (string | Node)[]
|
||||||
|
findAndReplaceEmojisInText(emojiRegEx, node.value, (match, result) => {
|
||||||
|
const attrs = getEmojiAttributes(match)
|
||||||
|
matches.push(result.slice(start))
|
||||||
|
matches.push(h('img', { src: attrs.src, alt: attrs.alt, class: attrs.class }))
|
||||||
|
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 replaceCustomEmoji(customEmojis: Record<string, Emoji>): Transform {
|
||||||
|
return (node) => {
|
||||||
|
if (node.type !== TEXT_NODE)
|
||||||
|
return node
|
||||||
|
|
||||||
|
const split = node.value.split(/:([\w-]+?):/g)
|
||||||
|
if (split.length === 1)
|
||||||
|
return node
|
||||||
|
|
||||||
|
return split.map((name, i) => {
|
||||||
|
if (i % 2 === 0)
|
||||||
|
return name
|
||||||
|
|
||||||
|
const emoji = customEmojis[name]
|
||||||
|
if (!emoji)
|
||||||
|
return `:${name}:`
|
||||||
|
|
||||||
|
return h('img', { 'src': emoji.url, 'alt': `:${name}:`, 'class': 'custom-emoji', 'data-emoji-id': name })
|
||||||
|
}).filter(Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMarkdown(): Transform {
|
||||||
|
const replacements: [RegExp, (c: (string | Node)[]) => Node][] = [
|
||||||
|
[/\*\*\*(.*?)\*\*\*/g, c => h('b', null, [h('em', null, c)])],
|
||||||
|
[/\*\*(.*?)\*\*/g, c => h('b', null, c)],
|
||||||
|
[/\*(.*?)\*/g, c => h('em', null, c)],
|
||||||
|
[/~~(.*?)~~/g, c => h('del', null, c)],
|
||||||
|
[/`([^`]+?)`/g, c => h('code', null, c)],
|
||||||
|
]
|
||||||
|
|
||||||
|
function process(value: string) {
|
||||||
|
const results = [] as (string | Node)[]
|
||||||
|
|
||||||
|
let start = 0
|
||||||
|
while (true) {
|
||||||
|
let found: { match: RegExpMatchArray; replacer: (c: (string | Node)[]) => Node } | undefined
|
||||||
|
|
||||||
|
for (const [re, replacer] of replacements) {
|
||||||
|
re.lastIndex = start
|
||||||
|
|
||||||
|
const match = re.exec(value)
|
||||||
|
if (match) {
|
||||||
|
if (!found || match.index < found.match.index!)
|
||||||
|
found = { match, replacer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
break
|
||||||
|
|
||||||
|
results.push(value.slice(start, found.match.index))
|
||||||
|
results.push(found.replacer(process(found.match[1])))
|
||||||
|
start = found.match.index! + found.match[0].length
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(value.slice(start))
|
||||||
|
return results.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (node) => {
|
||||||
|
if (node.type !== TEXT_NODE)
|
||||||
|
return node
|
||||||
|
return process(node.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Attachment, Status, StatusEdit } from 'masto'
|
import type { Attachment, Status, StatusEdit } from 'masto'
|
||||||
import type { Draft } from '~/types'
|
import type { Draft } from '~/types'
|
||||||
import { STORAGE_KEY_FIRST_VISIT, STORAGE_KEY_ZEN_MODE } from '~/constants'
|
import { STORAGE_KEY_FIRST_VISIT } from '~/constants'
|
||||||
|
|
||||||
export const mediaPreviewList = ref<Attachment[]>([])
|
export const mediaPreviewList = ref<Attachment[]>([])
|
||||||
export const mediaPreviewIndex = ref(0)
|
export const mediaPreviewIndex = ref(0)
|
||||||
|
|
@ -11,7 +11,6 @@ export const dialogDraftKey = ref<string>()
|
||||||
export const commandPanelInput = ref('')
|
export const commandPanelInput = ref('')
|
||||||
|
|
||||||
export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mock)
|
export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mock)
|
||||||
export const isZenMode = useLocalStorage(STORAGE_KEY_ZEN_MODE, false)
|
|
||||||
|
|
||||||
export const isSigninDialogOpen = ref(false)
|
export const isSigninDialogOpen = ref(false)
|
||||||
export const isPublishDialogOpen = ref(false)
|
export const isPublishDialogOpen = ref(false)
|
||||||
|
|
@ -22,8 +21,6 @@ export const isCommandPanelOpen = ref(false)
|
||||||
|
|
||||||
export const lastPublishDialogStatus = ref<Status | null>(null)
|
export const lastPublishDialogStatus = ref<Status | null>(null)
|
||||||
|
|
||||||
export const toggleZenMode = useToggle(isZenMode)
|
|
||||||
|
|
||||||
export function openSigninDialog() {
|
export function openSigninDialog() {
|
||||||
isSigninDialogOpen.value = true
|
isSigninDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -57,14 +54,33 @@ if (isPreviewHelpOpen.value) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreMediaPreviewFromState() {
|
||||||
|
mediaPreviewList.value = JSON.parse(history.state?.mediaPreviewList ?? '[]')
|
||||||
|
mediaPreviewIndex.value = history.state?.mediaPreviewIndex ?? 0
|
||||||
|
isMediaPreviewOpen.value = history.state?.mediaPreview ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
window.addEventListener('popstate', restoreMediaPreviewFromState)
|
||||||
|
|
||||||
|
restoreMediaPreviewFromState()
|
||||||
|
}
|
||||||
|
|
||||||
export function openMediaPreview(attachments: Attachment[], index = 0) {
|
export function openMediaPreview(attachments: Attachment[], index = 0) {
|
||||||
mediaPreviewList.value = attachments
|
mediaPreviewList.value = attachments
|
||||||
mediaPreviewIndex.value = index
|
mediaPreviewIndex.value = index
|
||||||
isMediaPreviewOpen.value = true
|
isMediaPreviewOpen.value = true
|
||||||
|
|
||||||
|
history.pushState({
|
||||||
|
...history.state,
|
||||||
|
mediaPreview: true,
|
||||||
|
mediaPreviewList: JSON.stringify(attachments),
|
||||||
|
mediaPreviewIndex: index,
|
||||||
|
}, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeMediaPreview() {
|
export function closeMediaPreview() {
|
||||||
isMediaPreviewOpen.value = false
|
history.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openEditHistoryDialog(edit: StatusEdit) {
|
export function openEditHistoryDialog(edit: StatusEdit) {
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { STORAGE_KEY_FEATURE_FLAGS } from '~/constants'
|
|
||||||
|
|
||||||
export interface FeatureFlags {
|
|
||||||
experimentalVirtualScroll: boolean
|
|
||||||
experimentalGitHubCards: boolean
|
|
||||||
experimentalUserPicker: boolean
|
|
||||||
}
|
|
||||||
export type FeatureFlagsMap = Record<string, FeatureFlags>
|
|
||||||
|
|
||||||
export function getDefaultFeatureFlags(): FeatureFlags {
|
|
||||||
return {
|
|
||||||
experimentalVirtualScroll: false,
|
|
||||||
experimentalGitHubCards: true,
|
|
||||||
experimentalUserPicker: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const currentUserFeatureFlags = process.server
|
|
||||||
? computed(getDefaultFeatureFlags)
|
|
||||||
: useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags)
|
|
||||||
|
|
||||||
export function useFeatureFlags() {
|
|
||||||
const featureFlags = currentUserFeatureFlags.value
|
|
||||||
|
|
||||||
return featureFlags
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleFeatureFlag(key: keyof FeatureFlags) {
|
|
||||||
const featureFlags = currentUserFeatureFlags.value
|
|
||||||
|
|
||||||
if (featureFlags[key])
|
|
||||||
featureFlags[key] = !featureFlags[key]
|
|
||||||
else
|
|
||||||
featureFlags[key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPicker = eagerComputed(() => useFeatureFlags().experimentalUserPicker)
|
|
||||||
export const showUserPicker = computed(() => useUsers().value.length > 1 && userPicker.value)
|
|
||||||
|
|
@ -24,7 +24,6 @@ export const useImageGesture = (
|
||||||
|
|
||||||
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
|
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
|
||||||
|
|
||||||
// @ts-expect-error we need to fix types: just suppress it for now
|
|
||||||
const handlers: Handlers = {
|
const handlers: Handlers = {
|
||||||
onPinch({ offset: [d] }) {
|
onPinch({ offset: [d] }) {
|
||||||
set({ scale: 1 + d / 200 })
|
set({ scale: 1 + d / 200 })
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const isHydrated = ref(false)
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import type { Account, Relationship, Status } from 'masto'
|
|
||||||
import { withoutProtocol } from 'ufo'
|
|
||||||
import { GUEST_ID } from './users'
|
|
||||||
import type { ElkMasto, UserLogin } from '~/types'
|
|
||||||
|
|
||||||
export const useMasto = () => useNuxtApp().$masto as ElkMasto
|
|
||||||
|
|
||||||
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
|
|
||||||
|
|
||||||
export const onMastoInit = (cb: () => unknown) => {
|
|
||||||
watchOnce(isMastoInitialised, () => {
|
|
||||||
cb()
|
|
||||||
}, { immediate: isMastoInitialised.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDisplayName(account?: Account, options?: { rich?: boolean }) {
|
|
||||||
const displayName = account?.displayName || account?.username || ''
|
|
||||||
if (options?.rich)
|
|
||||||
return displayName
|
|
||||||
return displayName.replace(/:([\w-]+?):/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShortHandle({ acct }: Account) {
|
|
||||||
if (!acct)
|
|
||||||
return ''
|
|
||||||
return `@${acct.includes('@') ? acct.split('@')[0] : acct}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerName(account: Account) {
|
|
||||||
if (account.acct?.includes('@'))
|
|
||||||
return account.acct.split('@')[1]
|
|
||||||
// We should only lack the server name if we're on the same server as the account
|
|
||||||
return currentInstance.value?.uri || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFullHandle(_account: Account | UserLogin) {
|
|
||||||
if ('guest' in _account && _account.guest)
|
|
||||||
return `${GUEST_ID}@${_account.server}`
|
|
||||||
|
|
||||||
const account = 'server' in _account ? _account.account : _account
|
|
||||||
const handle = `@${account.acct}`
|
|
||||||
if (!currentUser.value || account.acct.includes('@'))
|
|
||||||
return handle
|
|
||||||
return `${handle}@${getServerName(account)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toShortHandle(fullHandle: string) {
|
|
||||||
if (!currentUser.value)
|
|
||||||
return fullHandle
|
|
||||||
const server = currentUser.value.server
|
|
||||||
if (fullHandle.endsWith(`@${server}`))
|
|
||||||
return fullHandle.slice(0, -server.length - 1)
|
|
||||||
return fullHandle
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractAccountHandle(account: Account) {
|
|
||||||
let handle = getFullHandle(account).slice(1)
|
|
||||||
const uri = currentInstance.value?.uri ?? currentServer.value
|
|
||||||
if (currentInstance.value && handle.endsWith(`@${uri}`))
|
|
||||||
handle = handle.slice(0, -uri.length - 1)
|
|
||||||
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccountRoute(account: Account) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'account-index',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
account: extractAccountHandle(account),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
export function getAccountFollowingRoute(account: Account) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'account-following',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
account: extractAccountHandle(account),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
export function getAccountFollowersRoute(account: Account) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'account-followers',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
account: extractAccountHandle(account),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusRoute(status: Status) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'status',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
account: extractAccountHandle(status.account),
|
|
||||||
status: status.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTagRoute(tag: string) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'tag',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
tag,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusPermalinkRoute(status: Status) {
|
|
||||||
return status.url ? withoutProtocol(status.url) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatusInReplyToRoute(status: Status) {
|
|
||||||
return useRouter().resolve({
|
|
||||||
name: 'status-by-id',
|
|
||||||
params: {
|
|
||||||
server: currentServer.value,
|
|
||||||
status: status.inReplyToId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAccountHandle(account: Account, fullServer = true) {
|
|
||||||
return computed(() => fullServer
|
|
||||||
? getFullHandle(account)
|
|
||||||
: getShortHandle(account),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch requests for relationships when used in the UI
|
|
||||||
// We don't want to hold to old values, so every time a Relationship is needed it
|
|
||||||
// is requested again from the server to show the latest state
|
|
||||||
|
|
||||||
const requestedRelationships = new Map<string, Ref<Relationship | undefined>>()
|
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined
|
|
||||||
|
|
||||||
export function useRelationship(account: Account): Ref<Relationship | undefined> {
|
|
||||||
if (isGuest.value)
|
|
||||||
return ref()
|
|
||||||
let relationship = requestedRelationships.get(account.id)
|
|
||||||
if (relationship)
|
|
||||||
return relationship
|
|
||||||
relationship = ref<Relationship | undefined>()
|
|
||||||
requestedRelationships.set(account.id, relationship)
|
|
||||||
if (timeoutHandle)
|
|
||||||
clearTimeout(timeoutHandle)
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
timeoutHandle = undefined
|
|
||||||
fetchRelationships()
|
|
||||||
}, 100)
|
|
||||||
return relationship
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchRelationships() {
|
|
||||||
const requested = Array.from(requestedRelationships.entries()).filter(([, r]) => !r.value)
|
|
||||||
const relationships = await useMasto().accounts.fetchRelationships(requested.map(([id]) => id))
|
|
||||||
for (let i = 0; i < requested.length; i++)
|
|
||||||
requested[i][1].value = relationships[i]
|
|
||||||
}
|
|
||||||
58
composables/masto/account.ts
Normal file
58
composables/masto/account.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { Account } from 'masto'
|
||||||
|
import type { UserLogin } from '~/types'
|
||||||
|
|
||||||
|
export function getDisplayName(account?: Account, options?: { rich?: boolean }) {
|
||||||
|
const displayName = account?.displayName || account?.username || ''
|
||||||
|
if (options?.rich)
|
||||||
|
return displayName
|
||||||
|
return displayName.replace(/:([\w-]+?):/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortHandle({ acct }: Account) {
|
||||||
|
if (!acct)
|
||||||
|
return ''
|
||||||
|
return `@${acct.includes('@') ? acct.split('@')[0] : acct}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerName(account: Account) {
|
||||||
|
if (account.acct?.includes('@'))
|
||||||
|
return account.acct.split('@')[1]
|
||||||
|
// We should only lack the server name if we're on the same server as the account
|
||||||
|
return currentInstance.value?.uri || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFullHandle(_account: Account | UserLogin) {
|
||||||
|
if ('guest' in _account && _account.guest)
|
||||||
|
return `${GUEST_ID}@${_account.server}`
|
||||||
|
|
||||||
|
const account = 'server' in _account ? _account.account : _account
|
||||||
|
const handle = `@${account.acct}`
|
||||||
|
if (!currentUser.value || account.acct.includes('@'))
|
||||||
|
return handle
|
||||||
|
return `${handle}@${getServerName(account)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toShortHandle(fullHandle: string) {
|
||||||
|
if (!currentUser.value)
|
||||||
|
return fullHandle
|
||||||
|
const server = currentUser.value.server
|
||||||
|
if (fullHandle.endsWith(`@${server}`))
|
||||||
|
return fullHandle.slice(0, -server.length - 1)
|
||||||
|
return fullHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAccountHandle(account: Account) {
|
||||||
|
let handle = getFullHandle(account).slice(1)
|
||||||
|
const uri = currentInstance.value?.uri ?? currentServer.value
|
||||||
|
if (currentInstance.value && handle.endsWith(`@${uri}`))
|
||||||
|
handle = handle.slice(0, -uri.length - 1)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountHandle(account: Account, fullServer = true) {
|
||||||
|
return computed(() => fullServer
|
||||||
|
? getFullHandle(account)
|
||||||
|
: getShortHandle(account),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,45 +1,46 @@
|
||||||
// @unocss-include
|
// @unocss-include
|
||||||
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
|
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
|
||||||
Alipay: 'i-ri:alipay-fill',
|
Alipay: 'i-ri:alipay-line',
|
||||||
Bilibili: 'i-ri:bilibili-fill',
|
Bilibili: 'i-ri:bilibili-line',
|
||||||
Birth: 'i-ri:calendar-line',
|
Birth: 'i-ri:calendar-line',
|
||||||
Blog: 'i-ri:newspaper-line',
|
Blog: 'i-ri:newspaper-line',
|
||||||
City: 'i-ri:map-pin-2-line',
|
City: 'i-ri:map-pin-2-line',
|
||||||
Dingding: 'i-ri:dingding-fill',
|
Dingding: 'i-ri:dingding-line',
|
||||||
Discord: 'i-ri:discord-fill',
|
Discord: 'i-ri:discord-line',
|
||||||
Douban: 'i-ri:douban-fill',
|
Douban: 'i-ri:douban-line',
|
||||||
Facebook: 'i-ri:facebook-fill',
|
Facebook: 'i-ri:facebook-line',
|
||||||
GitHub: 'i-ri:github-fill',
|
GitHub: 'i-ri:github-line',
|
||||||
GitLab: 'i-ri:gitlab-fill',
|
GitLab: 'i-ri:gitlab-line',
|
||||||
Home: 'i-ri:home-2-line',
|
Home: 'i-ri:home-2-line',
|
||||||
Instagram: 'i-ri:instagram-line',
|
Instagram: 'i-ri:instagram-line',
|
||||||
Joined: 'i-ri:user-add-line',
|
Joined: 'i-ri:user-add-line',
|
||||||
Language: 'i-ri:translate-2',
|
Language: 'i-ri:translate-2',
|
||||||
Languages: 'i-ri:translate-2',
|
Languages: 'i-ri:translate-2',
|
||||||
LinkedIn: 'i-ri:linkedin-box-fill',
|
LinkedIn: 'i-ri:linkedin-box-line',
|
||||||
Location: 'i-ri:map-pin-2-line',
|
Location: 'i-ri:map-pin-2-line',
|
||||||
Mastodon: 'i-ri:mastodon-line',
|
Mastodon: 'i-ri:mastodon-line',
|
||||||
Medium: 'i-ri:medium-fill',
|
Medium: 'i-ri:medium-line',
|
||||||
Patreon: 'i-ri:patreon-fill',
|
Patreon: 'i-ri:patreon-line',
|
||||||
PayPal: 'i-ri:paypal-fill',
|
PayPal: 'i-ri:paypal-line',
|
||||||
PlayStation: 'i-ri:playstation-fill',
|
PlayStation: 'i-ri:playstation-line',
|
||||||
Portfolio: 'i-ri:link',
|
Portfolio: 'i-ri:link',
|
||||||
QQ: 'i-ri:qq-fill',
|
Pronouns: 'i-ri:contacts-line',
|
||||||
|
QQ: 'i-ri:qq-line',
|
||||||
Site: 'i-ri:link',
|
Site: 'i-ri:link',
|
||||||
Sponsors: 'i-ri:heart-3-line',
|
Sponsors: 'i-ri:heart-3-line',
|
||||||
Spotify: 'i-ri:spotify-fill',
|
Spotify: 'i-ri:spotify-line',
|
||||||
Steam: 'i-ri:steam-fill',
|
Steam: 'i-ri:steam-line',
|
||||||
Switch: 'i-ri:switch-fill',
|
Switch: 'i-ri:switch-line',
|
||||||
Telegram: 'i-ri:telegram-fill',
|
Telegram: 'i-ri:telegram-line',
|
||||||
Tumblr: 'i-ri:tumblr-fill',
|
Tumblr: 'i-ri:tumblr-line',
|
||||||
Twitch: 'i-ri:twitch-line',
|
Twitch: 'i-ri:twitch-line',
|
||||||
Twitter: 'i-ri:twitter-line',
|
Twitter: 'i-ri:twitter-line',
|
||||||
Website: 'i-ri:link',
|
Website: 'i-ri:link',
|
||||||
WeChat: 'i-ri:wechat-fill',
|
WeChat: 'i-ri:wechat-line',
|
||||||
Weibo: 'i-ri:weibo-fill',
|
Weibo: 'i-ri:weibo-line',
|
||||||
Xbox: 'i-ri:xbox-fill',
|
Xbox: 'i-ri:xbox-line',
|
||||||
YouTube: 'i-ri:youtube-line',
|
YouTube: 'i-ri:youtube-line',
|
||||||
Zhihu: 'i-ri:zhihu-fill',
|
Zhihu: 'i-ri:zhihu-line',
|
||||||
}).sort(([a], [b]) => a.localeCompare(b)))
|
}).sort(([a], [b]) => a.localeCompare(b)))
|
||||||
|
|
||||||
const accountFieldIconsLowercase = Object.fromEntries(
|
const accountFieldIconsLowercase = Object.fromEntries(
|
||||||
|
|
|
||||||
11
composables/masto/masto.ts
Normal file
11
composables/masto/masto.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { ElkMasto } from '~/types'
|
||||||
|
|
||||||
|
export const useMasto = () => useNuxtApp().$masto as ElkMasto
|
||||||
|
|
||||||
|
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
|
||||||
|
|
||||||
|
export const onMastoInit = (cb: () => unknown) => {
|
||||||
|
watchOnce(isMastoInitialised, () => {
|
||||||
|
cb()
|
||||||
|
}, { immediate: isMastoInitialised.value })
|
||||||
|
}
|
||||||
33
composables/masto/relationship.ts
Normal file
33
composables/masto/relationship.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { Account, Relationship } from 'masto'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
// Batch requests for relationships when used in the UI
|
||||||
|
// We don't want to hold to old values, so every time a Relationship is needed it
|
||||||
|
// is requested again from the server to show the latest state
|
||||||
|
|
||||||
|
const requestedRelationships = new Map<string, Ref<Relationship | undefined>>()
|
||||||
|
let timeoutHandle: NodeJS.Timeout | undefined
|
||||||
|
|
||||||
|
export function useRelationship(account: Account): Ref<Relationship | undefined> {
|
||||||
|
if (isGuest.value)
|
||||||
|
return ref()
|
||||||
|
let relationship = requestedRelationships.get(account.id)
|
||||||
|
if (relationship)
|
||||||
|
return relationship
|
||||||
|
relationship = ref<Relationship | undefined>()
|
||||||
|
requestedRelationships.set(account.id, relationship)
|
||||||
|
if (timeoutHandle)
|
||||||
|
clearTimeout(timeoutHandle)
|
||||||
|
timeoutHandle = setTimeout(() => {
|
||||||
|
timeoutHandle = undefined
|
||||||
|
fetchRelationships()
|
||||||
|
}, 100)
|
||||||
|
return relationship
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRelationships() {
|
||||||
|
const requested = Array.from(requestedRelationships.entries()).filter(([, r]) => !r.value)
|
||||||
|
const relationships = await useMasto().accounts.fetchRelationships(requested.map(([id]) => id))
|
||||||
|
for (let i = 0; i < requested.length; i++)
|
||||||
|
requested[i][1].value = relationships[i]
|
||||||
|
}
|
||||||
65
composables/masto/routes.ts
Normal file
65
composables/masto/routes.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { withoutProtocol } from 'ufo'
|
||||||
|
import type { Account, Status } from 'masto'
|
||||||
|
|
||||||
|
export function getAccountRoute(account: Account) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'account-index',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
account: extractAccountHandle(account),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function getAccountFollowingRoute(account: Account) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'account-following',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
account: extractAccountHandle(account),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function getAccountFollowersRoute(account: Account) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'account-followers',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
account: extractAccountHandle(account),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusRoute(status: Status) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'status',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
account: extractAccountHandle(status.account),
|
||||||
|
status: status.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTagRoute(tag: string) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'tag',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
tag,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusPermalinkRoute(status: Status) {
|
||||||
|
return status.url ? withoutProtocol(status.url) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusInReplyToRoute(status: Status) {
|
||||||
|
return useRouter().resolve({
|
||||||
|
name: 'status-by-id',
|
||||||
|
params: {
|
||||||
|
server: currentServer.value,
|
||||||
|
status: status.inReplyToId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,11 @@ import type { Mutable } from '~/types/utils'
|
||||||
|
|
||||||
export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
|
export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
|
||||||
|
|
||||||
|
export const builtinDraftKeys = [
|
||||||
|
'dialog',
|
||||||
|
'home',
|
||||||
|
]
|
||||||
|
|
||||||
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
|
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
|
||||||
const {
|
const {
|
||||||
attachments = [],
|
attachments = [],
|
||||||
|
|
@ -21,7 +26,6 @@ export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & O
|
||||||
return {
|
return {
|
||||||
attachments,
|
attachments,
|
||||||
initialText,
|
initialText,
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
status: status || '',
|
status: status || '',
|
||||||
inReplyToId,
|
inReplyToId,
|
||||||
|
|
@ -30,6 +34,7 @@ export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & O
|
||||||
spoilerText: spoilerText || '',
|
spoilerText: spoilerText || '',
|
||||||
language: language || 'en',
|
language: language || 'en',
|
||||||
},
|
},
|
||||||
|
lastUpdated: Date.now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,25 +83,27 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDraft(
|
export function useDraft(
|
||||||
draftKey: string,
|
draftKey?: string,
|
||||||
initial: () => Draft = () => getDefaultDraft({}),
|
initial: () => Draft = () => getDefaultDraft({}),
|
||||||
) {
|
) {
|
||||||
const draft = computed({
|
const draft = draftKey
|
||||||
get() {
|
? computed({
|
||||||
if (!currentUserDrafts.value[draftKey])
|
get() {
|
||||||
currentUserDrafts.value[draftKey] = initial()
|
if (!currentUserDrafts.value[draftKey])
|
||||||
return currentUserDrafts.value[draftKey]
|
currentUserDrafts.value[draftKey] = initial()
|
||||||
},
|
return currentUserDrafts.value[draftKey]
|
||||||
set(val) {
|
},
|
||||||
currentUserDrafts.value[draftKey] = val
|
set(val) {
|
||||||
},
|
currentUserDrafts.value[draftKey] = val
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
: ref(initial())
|
||||||
|
|
||||||
const isEmpty = computed(() => isEmptyDraft(draft.value))
|
const isEmpty = computed(() => isEmptyDraft(draft.value))
|
||||||
|
|
||||||
onUnmounted(async () => {
|
onUnmounted(async () => {
|
||||||
// Remove draft if it's empty
|
// Remove draft if it's empty
|
||||||
if (isEmpty.value) {
|
if (isEmpty.value && draftKey) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
delete currentUserDrafts.value[draftKey]
|
delete currentUserDrafts.value[draftKey]
|
||||||
}
|
}
|
||||||
|
|
@ -117,3 +124,12 @@ export function directMessageUser(account: Account) {
|
||||||
visibility: 'direct',
|
visibility: 'direct',
|
||||||
}), true)
|
}), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearEmptyDrafts() {
|
||||||
|
for (const key in currentUserDrafts.value) {
|
||||||
|
if (builtinDraftKeys.includes(key))
|
||||||
|
continue
|
||||||
|
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
||||||
|
delete currentUserDrafts.value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Status } from 'masto'
|
import type { Status, StatusEdit } from 'masto'
|
||||||
|
|
||||||
export interface TranslationResponse {
|
export interface TranslationResponse {
|
||||||
translatedText: string
|
translatedText: string
|
||||||
|
|
@ -24,15 +24,18 @@ export async function translateText(text: string, from?: string | null, to?: str
|
||||||
return translatedText
|
return translatedText
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations = new WeakMap<Status, { visible: boolean; text: string }>()
|
const translations = new WeakMap<Status | StatusEdit, { visible: boolean; text: string }>()
|
||||||
|
|
||||||
export function useTranslation(status: Status) {
|
export function useTranslation(status: Status | StatusEdit) {
|
||||||
if (!translations.has(status))
|
if (!translations.has(status))
|
||||||
translations.set(status, reactive({ visible: false, text: '' }))
|
translations.set(status, reactive({ visible: false, text: '' }))
|
||||||
|
|
||||||
const translation = translations.get(status)!
|
const translation = translations.get(status)!
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
|
if (!('language' in status))
|
||||||
|
return
|
||||||
|
|
||||||
if (!translation.text)
|
if (!translation.text)
|
||||||
translation.text = await translateText(status.content, status.language)
|
translation.text = await translateText(status.content, status.language)
|
||||||
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Paginator, WsEvents } from 'masto'
|
import type { Paginator, WsEvents } from 'masto'
|
||||||
import { useDeactivated } from './lifecycle'
|
|
||||||
import type { PaginatorState } from '~/types'
|
import type { PaginatorState } from '~/types'
|
||||||
|
|
||||||
export function usePaginator<T>(
|
export function usePaginator<T>(
|
||||||
|
|
|
||||||
36
composables/settings/featureFlags.ts
Normal file
36
composables/settings/featureFlags.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { userSettings } from '.'
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
experimentalVirtualScroll: boolean
|
||||||
|
experimentalGitHubCards: boolean
|
||||||
|
experimentalUserPicker: boolean
|
||||||
|
}
|
||||||
|
export type FeatureFlagsMap = Record<string, FeatureFlags>
|
||||||
|
|
||||||
|
const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
|
||||||
|
experimentalVirtualScroll: false,
|
||||||
|
experimentalGitHubCards: true,
|
||||||
|
experimentalUserPicker: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeatureFlag<T extends keyof FeatureFlags>(name: T): Ref<FeatureFlags[T]> {
|
||||||
|
return computed({
|
||||||
|
get() {
|
||||||
|
return getFeatureFlag(name)
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (userSettings.value)
|
||||||
|
userSettings.value.featureFlags[name] = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeatureFlag<T extends keyof FeatureFlags>(name: T): FeatureFlags[T] {
|
||||||
|
return userSettings.value?.featureFlags?.[name] ?? DEFAULT_FEATURE_FLAGS[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFeatureFlag(key: keyof FeatureFlags) {
|
||||||
|
const flag = useFeatureFlag(key)
|
||||||
|
flag.value = !flag.value
|
||||||
|
}
|
||||||
21
composables/settings/index.ts
Normal file
21
composables/settings/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { FeatureFlags } from './featureFlags'
|
||||||
|
import type { ColorMode, FontSize } from '~/types'
|
||||||
|
import { STORAGE_KEY_SETTINGS } from '~/constants'
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
featureFlags: Partial<FeatureFlags>
|
||||||
|
colorMode?: ColorMode
|
||||||
|
fontSize?: FontSize
|
||||||
|
lang?: string
|
||||||
|
zenMode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultUserSettings(): UserSettings {
|
||||||
|
return {
|
||||||
|
featureFlags: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userSettings = process.server
|
||||||
|
? computed(getDefaultUserSettings)
|
||||||
|
: useUserLocalStorage(STORAGE_KEY_SETTINGS, getDefaultUserSettings)
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { pwaInfo } from 'virtual:pwa-info'
|
|
||||||
import type { Link } from '@unhead/schema'
|
|
||||||
import type { Directions } from 'vue-i18n-routing'
|
import type { Directions } from 'vue-i18n-routing'
|
||||||
import { buildInfo } from 'virtual:build-info'
|
import { buildInfo } from 'virtual:build-info'
|
||||||
import type { LocaleObject } from '#i18n'
|
import type { LocaleObject } from '#i18n'
|
||||||
|
|
@ -7,28 +5,6 @@ import type { LocaleObject } from '#i18n'
|
||||||
export function setupPageHeader() {
|
export function setupPageHeader() {
|
||||||
const { locale, locales, t } = useI18n()
|
const { locale, locales, t } = useI18n()
|
||||||
|
|
||||||
const link: Link[] = []
|
|
||||||
|
|
||||||
if (pwaInfo && pwaInfo.webManifest) {
|
|
||||||
const { webManifest } = pwaInfo
|
|
||||||
if (webManifest) {
|
|
||||||
const { href, useCredentials } = webManifest
|
|
||||||
if (useCredentials) {
|
|
||||||
link.push({
|
|
||||||
rel: 'manifest',
|
|
||||||
href,
|
|
||||||
crossorigin: 'use-credentials',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
link.push({
|
|
||||||
rel: 'manifest',
|
|
||||||
href,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||||
acc[l.code!] = l.dir ?? 'auto'
|
acc[l.code!] = l.dir ?? 'auto'
|
||||||
return acc
|
return acc
|
||||||
|
|
@ -46,6 +22,12 @@ export function setupPageHeader() {
|
||||||
titleTemplate += ` (${buildInfo.env})`
|
titleTemplate += ` (${buildInfo.env})`
|
||||||
return titleTemplate
|
return titleTemplate
|
||||||
},
|
},
|
||||||
link,
|
link: process.client && useRuntimeConfig().public.pwaEnabled
|
||||||
|
? () => [{
|
||||||
|
key: 'webmanifest',
|
||||||
|
rel: 'manifest',
|
||||||
|
href: `/manifest-${locale.value}.webmanifest`,
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import Code from '@tiptap/extension-code'
|
||||||
import { Plugin } from 'prosemirror-state'
|
import { Plugin } from 'prosemirror-state'
|
||||||
|
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
import { HashtagSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||||
import { CodeBlockShiki } from './tiptap/shiki'
|
import { CodeBlockShiki } from './tiptap/shiki'
|
||||||
import { CustomEmoji } from './tiptap/custom-emoji'
|
import { CustomEmoji } from './tiptap/custom-emoji'
|
||||||
import { Emoji } from './tiptap/emoji'
|
import { Emoji } from './tiptap/emoji'
|
||||||
|
|
@ -54,9 +54,9 @@ export function useTiptap(options: UseTiptapOptions) {
|
||||||
suggestion: MentionSuggestion,
|
suggestion: MentionSuggestion,
|
||||||
}),
|
}),
|
||||||
Mention
|
Mention
|
||||||
.extend({ name: 'hastag' })
|
.extend({ name: 'hashtag' })
|
||||||
.configure({
|
.configure({
|
||||||
suggestion: HashSuggestion,
|
suggestion: HashtagSuggestion,
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: placeholder.value,
|
placeholder: placeholder.value,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,39 @@ import {
|
||||||
Node,
|
Node,
|
||||||
mergeAttributes,
|
mergeAttributes,
|
||||||
nodeInputRule,
|
nodeInputRule,
|
||||||
|
nodePasteRule,
|
||||||
} from '@tiptap/core'
|
} from '@tiptap/core'
|
||||||
import { emojiRegEx, getEmojiAttributes } from '~/config/emojis'
|
import { emojiRegEx, getEmojiAttributes } from '~/config/emojis'
|
||||||
|
|
||||||
|
const createEmojiRule = <NR extends typeof nodeInputRule | typeof nodePasteRule>(
|
||||||
|
nodeRule: NR,
|
||||||
|
type: Parameters<NR>[0]['type'],
|
||||||
|
): ReturnType<NR>[] => {
|
||||||
|
const rule = nodeRule({
|
||||||
|
find: emojiRegEx as RegExp,
|
||||||
|
type,
|
||||||
|
getAttributes: (match) => {
|
||||||
|
const [native] = match
|
||||||
|
return getEmojiAttributes(native)
|
||||||
|
},
|
||||||
|
}) as ReturnType<NR>
|
||||||
|
|
||||||
|
// Error catch for unsupported emoji
|
||||||
|
const handler = rule.handler.bind(rule)
|
||||||
|
rule.handler = (...args) => {
|
||||||
|
try {
|
||||||
|
return handler(...args)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
rule,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const Emoji = Node.create({
|
export const Emoji = Node.create({
|
||||||
name: 'em-emoji',
|
name: 'em-emoji',
|
||||||
|
|
||||||
|
|
@ -50,26 +80,10 @@ export const Emoji = Node.create({
|
||||||
},
|
},
|
||||||
|
|
||||||
addInputRules() {
|
addInputRules() {
|
||||||
const inputRule = nodeInputRule({
|
return createEmojiRule(nodeInputRule, this.type)
|
||||||
find: emojiRegEx as RegExp,
|
},
|
||||||
type: this.type,
|
|
||||||
getAttributes: (match) => {
|
addPasteRules() {
|
||||||
const [native] = match
|
return createEmojiRule(nodePasteRule, this.type)
|
||||||
return getEmojiAttributes(native)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// Error catch for unsupported emoji
|
|
||||||
const handler = inputRule.handler.bind(inputRule)
|
|
||||||
inputRule.handler = (...args) => {
|
|
||||||
try {
|
|
||||||
return handler(...args)
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
inputRule,
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import tippy from 'tippy.js'
|
||||||
import { VueRenderer } from '@tiptap/vue-3'
|
import { VueRenderer } from '@tiptap/vue-3'
|
||||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||||
import { PluginKey } from 'prosemirror-state'
|
import { PluginKey } from 'prosemirror-state'
|
||||||
|
import type { Component } from 'vue'
|
||||||
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
||||||
|
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
|
||||||
|
|
||||||
export const MentionSuggestion: Partial<SuggestionOptions> = {
|
export const MentionSuggestion: Partial<SuggestionOptions> = {
|
||||||
pluginKey: new PluginKey('mention'),
|
pluginKey: new PluginKey('mention'),
|
||||||
|
|
@ -17,29 +19,32 @@ export const MentionSuggestion: Partial<SuggestionOptions> = {
|
||||||
|
|
||||||
return results.value.accounts
|
return results.value.accounts
|
||||||
},
|
},
|
||||||
render: createSuggestionRenderer(),
|
render: createSuggestionRenderer(TiptapMentionList),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HashSuggestion: Partial<SuggestionOptions> = {
|
export const HashtagSuggestion: Partial<SuggestionOptions> = {
|
||||||
pluginKey: new PluginKey('hashtag'),
|
pluginKey: new PluginKey('hashtag'),
|
||||||
char: '#',
|
char: '#',
|
||||||
items({ query }) {
|
async items({ query }) {
|
||||||
// TODO: query
|
if (query.length === 0)
|
||||||
return [
|
return []
|
||||||
'TODO HASH QUERY',
|
|
||||||
].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5)
|
const paginator = useMasto().search({ q: query, type: 'hashtags', limit: 25, resolve: true })
|
||||||
|
const results = await paginator.next()
|
||||||
|
|
||||||
|
return results.value.hashtags
|
||||||
},
|
},
|
||||||
render: createSuggestionRenderer(),
|
render: createSuggestionRenderer(TiptapHashtagList),
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSuggestionRenderer(): SuggestionOptions['render'] {
|
function createSuggestionRenderer(component: Component): SuggestionOptions['render'] {
|
||||||
return () => {
|
return () => {
|
||||||
let component: VueRenderer
|
let renderer: VueRenderer
|
||||||
let popup: Instance
|
let popup: Instance
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart(props) {
|
onStart(props) {
|
||||||
component = new VueRenderer(TiptapMentionList, {
|
renderer = new VueRenderer(component, {
|
||||||
props,
|
props,
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
})
|
})
|
||||||
|
|
@ -50,7 +55,7 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
||||||
popup = tippy(document.body, {
|
popup = tippy(document.body, {
|
||||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||||
appendTo: () => document.body,
|
appendTo: () => document.body,
|
||||||
content: component.element,
|
content: renderer.element,
|
||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
|
|
@ -60,11 +65,11 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
||||||
|
|
||||||
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
|
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
|
||||||
onBeforeUpdate: (props) => {
|
onBeforeUpdate: (props) => {
|
||||||
component.updateProps({ ...props, isPending: true })
|
renderer.updateProps({ ...props, isPending: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
onUpdate(props) {
|
onUpdate(props) {
|
||||||
component.updateProps({ ...props, isPending: false })
|
renderer.updateProps({ ...props, isPending: false })
|
||||||
|
|
||||||
if (!props.clientRect)
|
if (!props.clientRect)
|
||||||
return
|
return
|
||||||
|
|
@ -79,12 +84,12 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
||||||
popup?.hide()
|
popup?.hide()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return component?.ref?.onKeyDown(props.event)
|
return renderer?.ref?.onKeyDown(props.event)
|
||||||
},
|
},
|
||||||
|
|
||||||
onExit() {
|
onExit() {
|
||||||
popup?.destroy()
|
popup?.destroy()
|
||||||
component?.destroy()
|
renderer?.destroy()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,37 @@ export const currentUserHandle = computed(() =>
|
||||||
currentUser.value.guest ? GUEST_ID : currentUser.value.account!.acct,
|
currentUser.value.guest ? GUEST_ID : currentUser.value.account!.acct,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// when multiple tabs: we need to reload window when sign in, switch account or sign out
|
||||||
|
if (process.client) {
|
||||||
|
const windowReload = () => {
|
||||||
|
document.visibilityState === 'visible' && window.location.reload()
|
||||||
|
}
|
||||||
|
watch(currentUserId, async (id, oldId) => {
|
||||||
|
// when sign in or switch account
|
||||||
|
if (id) {
|
||||||
|
if (id === currentUser.value?.account?.id) {
|
||||||
|
// 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)
|
||||||
|
// 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
|
||||||
|
if (document.hasFocus() || document.visibilityState === 'visible')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('visibilitychange', windowReload, { capture: true })
|
||||||
|
}
|
||||||
|
// when sign out
|
||||||
|
else if (oldId) {
|
||||||
|
const oldUser = users.value.find(user => user.account?.id === oldId)
|
||||||
|
// 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' })
|
||||||
|
}
|
||||||
|
|
||||||
export const useUsers = () => users
|
export const useUsers = () => users
|
||||||
|
|
||||||
export const characterLimit = computed(() => currentInstance.value?.configuration.statuses.maxCharacters ?? DEFAULT_POST_CHARS_LIMIT)
|
export const characterLimit = computed(() => currentInstance.value?.configuration.statuses.maxCharacters ?? DEFAULT_POST_CHARS_LIMIT)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import type { ComponentInternalInstance } from 'vue'
|
import type { ComponentInternalInstance } from 'vue'
|
||||||
import { onActivated, onDeactivated, ref } from 'vue'
|
import { onActivated, onDeactivated, ref } from 'vue'
|
||||||
|
import type { ActiveHeadEntry, HeadEntryOptions, UseHeadInput } from '@vueuse/head'
|
||||||
|
import type { HeadAugmentations } from '@nuxt/schema'
|
||||||
|
import { useHead } from '#head'
|
||||||
|
|
||||||
|
export const isHydrated = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ### Whether the current component is running in the background
|
* ### Whether the current component is running in the background
|
||||||
|
|
@ -28,3 +33,13 @@ export function onReactivated(hook: Function, target?: ComponentInternalInstance
|
||||||
}, target)
|
}, target)
|
||||||
onDeactivated(() => initial.value = false)
|
onDeactivated(() => initial.value = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Workaround for Nuxt bug: https://github.com/elk-zone/elk/pull/199#issuecomment-1329771961
|
||||||
|
export function useHeadFixed<T extends HeadAugmentations>(input: UseHeadInput<T>, options?: HeadEntryOptions): ActiveHeadEntry<UseHeadInput<T>> | void {
|
||||||
|
const deactivated = useDeactivated()
|
||||||
|
return useHead(() => {
|
||||||
|
if (deactivated.value)
|
||||||
|
return {}
|
||||||
|
return resolveUnref(input)
|
||||||
|
}, options)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import type { ActiveHeadEntry, HeadEntryOptions, UseHeadInput } from '@vueuse/head'
|
|
||||||
import type { HeadAugmentations } from '@nuxt/schema'
|
|
||||||
import { useHead } from '#head'
|
|
||||||
|
|
||||||
// TODO: Workaround for Nuxt bug: https://github.com/elk-zone/elk/pull/199#issuecomment-1329771961
|
|
||||||
export function useHeadFixed<T extends HeadAugmentations>(input: UseHeadInput<T>, options?: HeadEntryOptions): ActiveHeadEntry<UseHeadInput<T>> | void {
|
|
||||||
const deactivated = useDeactivated()
|
|
||||||
return useHead(() => {
|
|
||||||
if (deactivated.value)
|
|
||||||
return {}
|
|
||||||
return resolveUnref(input)
|
|
||||||
}, options)
|
|
||||||
}
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const getEnv = async () => {
|
||||||
: isPreview
|
: isPreview
|
||||||
? 'preview'
|
? 'preview'
|
||||||
: branch === 'main'
|
: branch === 'main'
|
||||||
? 'main'
|
? 'canary'
|
||||||
: 'release'
|
: 'release'
|
||||||
return { commit, branch, env } as const
|
return { commit, branch, env } as const
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ const locales: LocaleObjectData[] = [
|
||||||
file: 'ja-JP.json',
|
file: 'ja-JP.json',
|
||||||
name: '日本語',
|
name: '日本語',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
code: 'nl-NL',
|
||||||
|
file: 'nl-NL.json',
|
||||||
|
name: 'Nederlands',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: 'es-ES',
|
code: 'es-ES',
|
||||||
file: 'es-ES.json',
|
file: 'es-ES.json',
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue