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/
|
||||
.vite-inspect
|
||||
.netlify/
|
||||
.eslintcache
|
||||
|
||||
public/shiki
|
||||
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": [
|
||||
"masto",
|
||||
"Nuxtodon",
|
||||
"unmute",
|
||||
"unstorage"
|
||||
],
|
||||
"i18n-ally.localesPaths": [
|
||||
"locales"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"i18n-ally.preferredDelimiter": "_",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"files.associations": {
|
||||
"*.css": "postcss"
|
||||
},
|
||||
"i18n-ally.keysInUse": [
|
||||
"time_ago_options.*",
|
||||
"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 />
|
||||
</NuxtLayout>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Account } from 'masto'
|
|||
|
||||
defineProps<{
|
||||
account: Account
|
||||
square?: boolean
|
||||
}>()
|
||||
|
||||
const loaded = $ref(false)
|
||||
|
|
@ -17,8 +18,8 @@ const error = $ref(false)
|
|||
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
||||
:alt="$t('account.avatar_description', [account.username])"
|
||||
loading="lazy"
|
||||
rounded-full
|
||||
:class="loaded ? 'bg-base' : 'bg-gray:10'"
|
||||
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
|
||||
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
|
||||
v-bind="$attrs"
|
||||
@load="loaded = true"
|
||||
@error="error = true"
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import type { Account } from 'masto'
|
|||
|
||||
defineProps<{
|
||||
account: Account
|
||||
square?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -32,13 +32,7 @@ defineOptions({
|
|||
</a>
|
||||
</div>
|
||||
<div sm:mt-2>
|
||||
<div>
|
||||
<ContentRich
|
||||
font-bold text-lg line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
/>
|
||||
</div>
|
||||
<AccountDisplayName :account="account" font-bold text-lg line-clamp-1 ws-pre-wrap break-all />
|
||||
<AccountHandle text-sm :account="account" />
|
||||
</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">
|
||||
import type { Account, Field } from 'masto'
|
||||
import { getAccountFieldIcon } from '~/composables/masto/icons'
|
||||
|
||||
const { account } = defineProps<{
|
||||
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">
|
||||
<AccountAvatar :account="account" hover:opacity-90 transition-opacity />
|
||||
</button>
|
||||
<div flex flex-col>
|
||||
<div flex="~ col gap1">
|
||||
<div flex justify-between>
|
||||
<ContentRich
|
||||
font-bold sm:text-2xl text-xl
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
:markdown="false"
|
||||
/>
|
||||
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
|
||||
<AccountBotIndicator v-if="account.bot" />
|
||||
</div>
|
||||
<AccountHandle :account="account" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const { account, as = 'div' } = defineProps<{
|
|||
account: Account
|
||||
as?: string
|
||||
hoverCard?: boolean
|
||||
square?: boolean
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
|
|
@ -17,15 +18,11 @@ defineOptions({
|
|||
<template>
|
||||
<component :is="as" flex gap-3 v-bind="$attrs">
|
||||
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
||||
<AccountBigAvatar :account="account" shrink-0 />
|
||||
<AccountBigAvatar :account="account" shrink-0 :square="square" />
|
||||
</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>
|
||||
<ContentRich
|
||||
font-bold line-clamp-1 ws-pre-wrap break-all text-lg
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
/>
|
||||
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
||||
<AccountBotIndicator v-if="account.bot" />
|
||||
</div>
|
||||
<AccountHandle :account="account" text-secondary-light />
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@ const { link = true, avatar = true } = defineProps<{
|
|||
min-w-0 flex gap-2 items-center
|
||||
>
|
||||
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
||||
<ContentRich
|
||||
line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
/>
|
||||
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
// type used in <template>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { Account } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -14,8 +16,9 @@ defineProps<{
|
|||
</div>
|
||||
|
||||
<div flex>
|
||||
<NuxtLink :to="getAccountRoute(account.moved as any)">
|
||||
<AccountInfo :account="account.moved" />
|
||||
<!-- type error of masto.js -->
|
||||
<NuxtLink :to="getAccountRoute(account.moved as unknown as Account)">
|
||||
<AccountInfo :account="account.moved as unknown as Account" />
|
||||
</NuxtLink>
|
||||
<div flex-auto />
|
||||
<div flex items-center>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -37,11 +37,23 @@ const searchResult = $computed<QueryResult>(() => {
|
|||
if (query.length === 0 || loading.value)
|
||||
return { length: 0, items: [], grouped: {} as any }
|
||||
|
||||
// TODO extract this scope
|
||||
// duplicate in SearchWidget.vue
|
||||
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)
|
||||
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)
|
||||
|
||||
const grouped: QueryResult['grouped'] = new Map()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: boolean
|
||||
}>(), {
|
||||
modelValue: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const { modelValue: visible } = defineModel<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
function 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>
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||
<span
|
||||
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
||||
text-lg
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
|
|
@ -23,7 +25,6 @@ const { modelValue } = defineModel<{
|
|||
type="checkbox"
|
||||
sr-only
|
||||
>
|
||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { Cropper } from 'vue-advanced-cropper'
|
|||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
export interface Props {
|
||||
/** Images to be cropped */
|
||||
modelValue?: File
|
||||
/** Crop frame aspect ratio (width/height), default 1/1 */
|
||||
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 */
|
||||
|
|
@ -16,12 +14,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
stencilSizePercentage: 0.9,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: File): void
|
||||
const { modelValue: file } = defineModel<{
|
||||
/** Images to be cropped */
|
||||
modelValue: File | null
|
||||
}>()
|
||||
|
||||
const vmFile = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
|
||||
const cropperDialog = ref(false)
|
||||
|
||||
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
|
||||
onCleanup(() => expired = true)
|
||||
|
||||
|
|
@ -59,12 +56,12 @@ watch(vmFile, (file, _, onCleanup) => {
|
|||
})
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropper.value && vmFile.value) {
|
||||
if (cropper.value && file.value) {
|
||||
cropperFlag.value = true
|
||||
cropperDialog.value = false
|
||||
const { canvas } = cropper.value.getResult()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { fileOpen } from 'browser-fs-access'
|
|||
import type { FileWithHandle } from 'browser-fs-access'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: FileWithHandle
|
||||
/** The image src before change */
|
||||
original?: string
|
||||
/** Allowed file types */
|
||||
|
|
@ -19,12 +18,13 @@ const props = withDefaults(defineProps<{
|
|||
allowedFileSize: 1024 * 1024 * 5, // 5 MB
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: FileWithHandle): void
|
||||
(event: 'pick', value: FileWithHandle): 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// @ts-expect-error missing types
|
||||
import { DynamicScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import type { Account, Paginator, WsEvents } from 'masto'
|
||||
import type { Paginator, WsEvents } from 'masto'
|
||||
|
||||
const {
|
||||
paginator,
|
||||
|
|
@ -11,7 +11,6 @@ const {
|
|||
virtualScroller = false,
|
||||
eventType = 'update',
|
||||
preprocess,
|
||||
isAccountTimeline,
|
||||
} = defineProps<{
|
||||
paginator: Paginator<any, any[]>
|
||||
keyProp?: string
|
||||
|
|
@ -19,7 +18,6 @@ const {
|
|||
stream?: Promise<WsEvents>
|
||||
eventType?: 'notification' | 'update'
|
||||
preprocess?: (items: any[]) => any[]
|
||||
isAccountTimeline?: boolean
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
|
|
@ -34,18 +32,9 @@ defineSlots<{
|
|||
update: () => void
|
||||
}
|
||||
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)
|
||||
</script>
|
||||
|
||||
|
|
@ -84,15 +73,11 @@ const { items, prevItems, update, state, endAnchor, error } = usePaginator(pagin
|
|||
<slot v-if="state === 'loading'" name="loading">
|
||||
<TimelineSkeleton />
|
||||
</slot>
|
||||
<div v-else-if="state === 'done'" p5 text-secondary italic text-center flex flex-col items-center gap1>
|
||||
<template v-if="isAccountTimeline">
|
||||
<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>
|
||||
<slot v-else-if="state === 'done'" name="done">
|
||||
<div p5 text-secondary italic text-center>
|
||||
{{ $t('common.end_of_list') }}
|
||||
</template>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-else-if="state === 'error'" p5 text-secondary>
|
||||
{{ $t('common.error') }}: {{ error }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ const { modelValue } = defineModel<{
|
|||
<template>
|
||||
<label
|
||||
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"
|
||||
>
|
||||
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||
<span
|
||||
:class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
|
||||
aria-hidden="true"
|
||||
|
|
@ -25,7 +26,6 @@ const { modelValue } = defineModel<{
|
|||
:value="value"
|
||||
sr-only
|
||||
>
|
||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ useCommands(() => command
|
|||
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
||||
@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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import sparkline from '@fnando/sparkline'
|
|||
|
||||
const {
|
||||
history,
|
||||
width = 60,
|
||||
height = 40,
|
||||
} = $defineProps<{
|
||||
history?: History[]
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
|
||||
const historyNum = $computed(() => {
|
||||
|
|
@ -24,5 +28,5 @@ watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
|||
</script>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const emit = defineEmits<{
|
|||
<p flex="~ gap-2 wrap" mxa>
|
||||
<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">
|
||||
<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>
|
||||
</template>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ defineProps<{
|
|||
pt="[env(safe-area-inset-top,0)]"
|
||||
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>
|
||||
<NuxtLink
|
||||
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0
|
||||
:class="{ 'lg:hidden': backOnSmallScreen }"
|
||||
:aria-label="$t('nav.back')"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<div i-ri:arrow-left-line class="rtl-flip" />
|
||||
|
|
@ -37,6 +38,7 @@ defineProps<{
|
|||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div hidden xl:block h-6 />
|
||||
<slot />
|
||||
</div>
|
||||
</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 -->
|
||||
<PublishWidget
|
||||
v-if="dialogDraftKey"
|
||||
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
||||
@published="handlePublished"
|
||||
/>
|
||||
|
|
@ -65,7 +66,7 @@ const handlePublishClose = () => {
|
|||
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
|
||||
</ModalDialog>
|
||||
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
|
||||
<StatusEditPreview :edit="statusEdit" />
|
||||
<StatusEditPreview v-if="statusEdit" :edit="statusEdit" />
|
||||
</ModalDialog>
|
||||
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
|
||||
<CommandPanel @close="closeCommandPanel()" />
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
|
||||
export interface Props {
|
||||
/** v-model dislog visibility */
|
||||
modelValue: boolean
|
||||
|
||||
/**
|
||||
* level of depth
|
||||
*
|
||||
|
|
@ -48,11 +45,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
const emit = defineEmits<{
|
||||
/** v-model dialog visibility */
|
||||
(event: 'update:modelValue', value: boolean): 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 route = useRoute()
|
||||
|
|
|
|||
|
|
@ -3,19 +3,20 @@ import { SwipeDirection } from '@vueuse/core'
|
|||
import { useReducedMotion } from '@vueuse/motion'
|
||||
import type { Attachment } from 'masto'
|
||||
|
||||
const props = withDefaults(defineProps<{ media: Attachment[]; threshold?: number; modelValue: number }>(), {
|
||||
media: [] as any,
|
||||
threshold: 20,
|
||||
modelValue: 0,
|
||||
})
|
||||
const { media = [], threshold = 20 } = defineProps<{
|
||||
media?: Attachment[]
|
||||
threshold?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const { modelValue } = defineModel<{
|
||||
modelValue: number
|
||||
}>()
|
||||
|
||||
const target = ref()
|
||||
const index = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const animateTimeout = useTimeout(10)
|
||||
const reduceMotion = useReducedMotion()
|
||||
|
|
@ -28,15 +29,15 @@ const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
|
|||
passive: false,
|
||||
onSwipeEnd(e, direction) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > props.threshold)
|
||||
index.value = Math.max(0, index.value - 1)
|
||||
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold)
|
||||
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > props.threshold)
|
||||
index.value = Math.min(props.media.length - 1, index.value + 1)
|
||||
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold)
|
||||
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||
|
||||
// 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')
|
||||
},
|
||||
})
|
||||
|
|
@ -46,9 +47,9 @@ const distanceX = computed(() => {
|
|||
return 0
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ const moreMenuVisible = ref(false)
|
|||
<div i-ri:earth-line />
|
||||
</NuxtLink>
|
||||
</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
|
||||
flex items-center place-content-center h-full flex-1 class="select-none"
|
||||
: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:more-fill />
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
modelValue?: boolean
|
||||
let { modelValue } = $defineModel<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const colorMode = useColorMode()
|
||||
|
||||
function changeShow() {
|
||||
visible.value = !visible.value
|
||||
function toggleVisible() {
|
||||
modelValue = !modelValue
|
||||
}
|
||||
|
||||
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 */
|
||||
function clickEvent(mouse: MouseEvent) {
|
||||
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
||||
if (visible.value) {
|
||||
if (modelValue) {
|
||||
document.removeEventListener('click', clickEvent)
|
||||
visible.value = false
|
||||
modelValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +23,7 @@ function toggleDark() {
|
|||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
watch(visible, (val) => {
|
||||
watch($$(modelValue), (val) => {
|
||||
if (val && typeof document !== 'undefined')
|
||||
document.addEventListener('click', clickEvent)
|
||||
})
|
||||
|
|
@ -39,7 +35,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
<template>
|
||||
<div ref="buttonEl" flex items-center static>
|
||||
<slot :change-show="changeShow" :show="visible" />
|
||||
<slot :toggle-visible="toggleVisible" :show="modelValue" />
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition
|
||||
|
|
@ -51,7 +47,7 @@ onBeforeUnmount(() => {
|
|||
leave-to-class="opacity-0 children:(transform translate-y-full)"
|
||||
>
|
||||
<div
|
||||
v-show="visible"
|
||||
v-show="modelValue"
|
||||
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
|
||||
bg="black/50"
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ function toggleDark() {
|
|||
<button
|
||||
flex
|
||||
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')"
|
||||
@click="toggleZenMode()"
|
||||
@click="userSettings.zenMode = !userSettings.zenMode"
|
||||
/>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ const { notifications } = useNotifications()
|
|||
</script>
|
||||
|
||||
<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.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
||||
<template #icon>
|
||||
|
|
@ -18,16 +23,17 @@ const { notifications } = useNotifications()
|
|||
</div>
|
||||
</template>
|
||||
</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.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('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" />
|
||||
</nav>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,11 @@ useCommand({
|
|||
|
||||
let activeClass = $ref('text-primary')
|
||||
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
|
||||
// we don't have currentServer defined until later
|
||||
activeClass = ''
|
||||
await nextTick()
|
||||
activeClass = 'text-primary'
|
||||
}
|
||||
})
|
||||
|
||||
// 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">
|
||||
<div
|
||||
flex items-center gap4
|
||||
w-fit rounded-full
|
||||
w-fit rounded-3
|
||||
px2 py2 mx3 sm:mxa
|
||||
xl="mx0 px5"
|
||||
xl="ml0 mr5 px5 w-auto"
|
||||
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">
|
||||
<div :class="icon" text-xl />
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ const { env } = buildInfo
|
|||
|
||||
<template>
|
||||
<!-- Use external to force refresh page and jump to top of timeline -->
|
||||
<div flex justify-between>
|
||||
<NuxtLink
|
||||
flex items-end gap-2
|
||||
w-fit
|
||||
py2 px-2 xl:px-3
|
||||
text-2xl hover:bg-active
|
||||
flex items-end gap-4
|
||||
py2 px-5
|
||||
text-2xl
|
||||
focus-visible:ring="2 current"
|
||||
rounded-full
|
||||
to="/"
|
||||
external
|
||||
>
|
||||
|
|
@ -21,4 +20,13 @@ const { env } = buildInfo
|
|||
{{ $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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
h-8
|
||||
w-8
|
||||
:draggable="false"
|
||||
square
|
||||
/>
|
||||
<div v-else bg="gray/40" rounded-full w-8 h-8 flex items-center justify-center text-5>
|
||||
G
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
<UserSwitcher ref="switcher" @click="hide()" />
|
||||
</template>
|
||||
</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') }}
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@ const { notification } = defineProps<{
|
|||
:lang="notification.status?.language ?? undefined"
|
||||
>
|
||||
<div i-ri:user-follow-fill me-1 color-primary />
|
||||
<ContentRich
|
||||
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(notification.account, { rich: true })"
|
||||
:emojis="notification.account.emojis"
|
||||
/>
|
||||
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||
<span ws-nowrap>
|
||||
{{ $t('notification.followed_you') }}
|
||||
</span>
|
||||
|
|
@ -36,10 +32,9 @@ const { notification } = defineProps<{
|
|||
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||
<div flex p3 items-center bg-shaded>
|
||||
<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
|
||||
:content="getDisplayName(notification.account, { rich: true })"
|
||||
:emojis="notification.account.emojis"
|
||||
/>
|
||||
<span>{{ $t("notification.signed_up") }}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
showReAuthMessage: boolean
|
||||
withHeader?: boolean
|
||||
closeableHeader?: boolean
|
||||
busy?: boolean
|
||||
animate?: boolean
|
||||
}>()
|
||||
|
|
@ -16,15 +15,22 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" gap-y-2 role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
|
||||
<header v-if="withHeader" flex items-center pb-2>
|
||||
<div
|
||||
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>
|
||||
{{ $t('notification.settings.warning.enable_title') }}
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_title') }}
|
||||
</h2>
|
||||
<button
|
||||
v-if="closeableHeader"
|
||||
flex rounded-4
|
||||
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
|
||||
:disabled="busy"
|
||||
@click="$emit('hide')"
|
||||
|
|
@ -33,10 +39,10 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
|||
</button>
|
||||
</header>
|
||||
<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 v-if="isLegacyAccount && showReAuthMessage">
|
||||
{{ $t('notification.settings.warning.re_auth') }}
|
||||
<p v-if="isLegacyAccount">
|
||||
{{ $t('settings.notifications.push_notifications.warning.re_auth') }}
|
||||
</p>
|
||||
<button
|
||||
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
|
||||
|
|
@ -46,8 +52,8 @@ const isLegacyAccount = computed(() => !currentUser.value.vapidKey)
|
|||
@click="$emit('subscribe')"
|
||||
>
|
||||
<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>
|
||||
<slot v-if="showReAuthMessage" name="error" />
|
||||
<slot name="error" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -35,10 +35,9 @@ const lang = $computed(() => {
|
|||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ContentRich
|
||||
<AccountDisplayName
|
||||
:account="items.items[0]?.account"
|
||||
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>
|
||||
{{ $t('notification.followed_you') }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<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 { 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<{
|
||||
paginator: Paginator<any, Notification[]>
|
||||
|
|
@ -118,12 +122,12 @@ const { formatNumber } = useHumanReadableNumber()
|
|||
/>
|
||||
<NotificationGroupedLikes
|
||||
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
||||
:group="item"
|
||||
:group="item as GroupedLikeNotifications"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationCard
|
||||
v-else
|
||||
:notification="item"
|
||||
:notification="item as Notification"
|
||||
hover:bg-active
|
||||
border="b base"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import NotificationSubscribePushNotificationError
|
||||
from '~/components/notification/NotificationSubscribePushNotificationError.vue'
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
defineProps<{ show?: boolean }>()
|
||||
|
||||
const {
|
||||
pushNotificationData,
|
||||
|
|
@ -71,12 +68,12 @@ const doSubscribe = async () => {
|
|||
try {
|
||||
const result = await subscribe()
|
||||
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
|
||||
}
|
||||
}
|
||||
catch {
|
||||
subscribeError = t('notification.settings.subscription_error.request_error')
|
||||
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||
showSubscribeError = true
|
||||
}
|
||||
finally {
|
||||
|
|
@ -103,40 +100,41 @@ onActivated(() => (busy = false))
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pwaEnabled && (showWarning || show)">
|
||||
<section v-if="pwaEnabled && (showWarning || show)" aria-labelledby="pn-s">
|
||||
<Transition name="slide-down">
|
||||
<div v-if="show" flex="~ col" border="b base" px5 py4>
|
||||
<header flex items-center pb-2>
|
||||
<h2 id="notifications-title" text-md font-bold w-full>
|
||||
{{ $t('notification.settings.title') }}
|
||||
</h2>
|
||||
</header>
|
||||
<div v-if="show" flex="~ col" border="b base">
|
||||
<h3 id="pn-settings" px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.notifications.push_notifications.label') }}
|
||||
</h3>
|
||||
<template v-if="isSupported">
|
||||
<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>
|
||||
<legend>{{ $t('notification.settings.alerts.title') }}</legend>
|
||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('notification.settings.alerts.follow')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('notification.settings.alerts.favourite')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('notification.settings.alerts.reblog')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('notification.settings.alerts.mention')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('notification.settings.alerts.poll')" />
|
||||
<legend>{{ $t('settings.notifications.push_notifications.alerts.title') }}</legend>
|
||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('settings.notifications.push_notifications.alerts.follow')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('settings.notifications.push_notifications.alerts.favourite')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('settings.notifications.push_notifications.alerts.reblog')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('settings.notifications.push_notifications.alerts.mention')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('settings.notifications.push_notifications.alerts.poll')" />
|
||||
</fieldset>
|
||||
<fieldset flex="~ col" gap-y-1 py-1>
|
||||
<legend>{{ $t('notification.settings.policy.title') }}</legend>
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('notification.settings.policy.all')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('notification.settings.policy.followed')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('notification.settings.policy.follower')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('notification.settings.policy.none')" />
|
||||
<legend>{{ $t('settings.notifications.push_notifications.policy.title') }}</legend>
|
||||
<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('settings.notifications.push_notifications.policy.followed')" />
|
||||
<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('settings.notifications.push_notifications.policy.none')" />
|
||||
</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
|
||||
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||
:disabled="busy || !saveEnabled"
|
||||
>
|
||||
<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
|
||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
|
|
@ -146,7 +144,7 @@ onActivated(() => (busy = false))
|
|||
@click="undoChanges"
|
||||
>
|
||||
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
|
||||
{{ $t('notification.settings.undo_settings') }}
|
||||
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -158,19 +156,14 @@ onActivated(() => (busy = false))
|
|||
:disabled="busy"
|
||||
>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="showWarning" role="alert" aria-labelledby="notifications-title">
|
||||
{{ $t('notification.settings.unsubscribed_with_warning') }}
|
||||
</p>
|
||||
<NotificationEnablePushNotification
|
||||
v-else
|
||||
:animate="animateSubscription"
|
||||
:busy="busy"
|
||||
:show-re-auth-message="!showWarning"
|
||||
@hide="hideNotification"
|
||||
@subscribe="doSubscribe"
|
||||
>
|
||||
|
|
@ -185,15 +178,16 @@ onActivated(() => (busy = false))
|
|||
</NotificationEnablePushNotification>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else role="alert" aria-labelledby="notifications-unsupported">
|
||||
{{ $t('notification.settings.unsupported') }}
|
||||
<div v-else px6 pb4 role="alert" aria-labelledby="n-unsupported">
|
||||
<p id="n-unsupported">
|
||||
{{ $t('settings.notifications.push_notifications.unsupported') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<NotificationEnablePushNotification
|
||||
v-if="showWarning"
|
||||
show-re-auth-message
|
||||
with-header
|
||||
v-if="showWarning && !show"
|
||||
closeable-header
|
||||
px5
|
||||
py4
|
||||
:animate="animateSubscription"
|
||||
|
|
@ -210,5 +204,5 @@ onActivated(() => (busy = false))
|
|||
</Transition>
|
||||
</template>
|
||||
</NotificationEnablePushNotification>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ const { modelValue } = defineModel<{
|
|||
<head id="notification-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<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>
|
||||
<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
|
||||
flex rounded-4 p1
|
||||
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"
|
||||
>
|
||||
<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
|
||||
flex="~ gap2 center"
|
||||
w-9 h-9 py2
|
||||
xl="w-auto h-auto py-4"
|
||||
rounded-full
|
||||
xl="w-auto h-auto"
|
||||
rounded-3
|
||||
cursor-pointer disabled:pointer-events-none
|
||||
text-primary font-bold
|
||||
text-primary
|
||||
border-1 border-primary
|
||||
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
|
||||
:disabled="disabled"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { statusVisibilities } from '~/composables/masto/icons'
|
||||
|
||||
const { editing } = defineProps<{
|
||||
editing?: boolean
|
||||
}>()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const {
|
|||
placeholder,
|
||||
dialogLabelledBy,
|
||||
} = defineProps<{
|
||||
draftKey: string
|
||||
draftKey?: string
|
||||
initial?: () => Draft
|
||||
placeholder?: string
|
||||
inReplyToId?: string
|
||||
|
|
@ -38,7 +38,10 @@ const shouldExpanded = $computed(() => _expanded || isExpanded || !isEmpty)
|
|||
const { editor } = useTiptap({
|
||||
content: computed({
|
||||
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')),
|
||||
autofocus: shouldExpanded,
|
||||
|
|
@ -194,7 +197,7 @@ defineExpose({
|
|||
|
||||
<div flex gap-3 flex-1>
|
||||
<NuxtLink :to="getAccountRoute(currentUser.account)">
|
||||
<AccountBigAvatar :account="currentUser.account" />
|
||||
<AccountBigAvatar :account="currentUser.account" square />
|
||||
</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 -->
|
||||
<div
|
||||
|
|
@ -266,7 +269,7 @@ defineExpose({
|
|||
<PublishAttachment
|
||||
v-for="(att, idx) in draft.attachments" :key="att.id"
|
||||
:attachment="att"
|
||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : null)"
|
||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
||||
@remove="removeAttachment(idx)"
|
||||
@set-description="setDescription(att, $event)"
|
||||
/>
|
||||
|
|
@ -343,7 +346,7 @@ defineExpose({
|
|||
</PublishVisibilityPicker>
|
||||
|
||||
<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)"
|
||||
@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">
|
||||
import type { SearchResult } from './types'
|
||||
defineProps<{ result: SearchResult; active: boolean }>()
|
||||
|
||||
defineProps<{
|
||||
result: SearchResult
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const onActivate = () => {
|
||||
(document.activeElement as HTMLElement).blur()
|
||||
|
|
@ -8,12 +12,20 @@ const onActivate = () => {
|
|||
</script>
|
||||
|
||||
<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" />
|
||||
<AccountInfo v-else-if="result.type === 'account'" :account="result.account" />
|
||||
<StatusCard v-else-if="result.type === 'status'" :status="result.status" :actions="false" :show-reply-to="false" />
|
||||
<div v-else-if="result.type === 'action'" text-center>
|
||||
<SearchAccountInfo v-else-if="result.type === 'account' && result.account" :account="result.account" />
|
||||
<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>
|
||||
{{ result.action!.label }}
|
||||
</div>
|
||||
</div> -->
|
||||
</CommonScrollIntoView>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccountResult, HashTagResult, StatusResult } from './types'
|
||||
|
||||
const query = ref('')
|
||||
const { accounts, hashtags, loading, statuses } = useSearch(query)
|
||||
const index = ref(0)
|
||||
|
|
@ -13,9 +15,24 @@ const results = computed(() => {
|
|||
return []
|
||||
|
||||
const results = [
|
||||
...hashtags.value.slice(0, 3).map(hashtag => ({ type: 'hashtag', hashtag, to: getTagRoute(hashtag.name) })),
|
||||
...accounts.value.map(account => ({ type: 'account', account, to: getAccountRoute(account) })),
|
||||
...statuses.value.map(status => ({ type: 'status', status, to: getStatusRoute(status) })),
|
||||
...hashtags.value.slice(0, 3).map<HashTagResult>(hashtag => ({
|
||||
type: 'hashtag',
|
||||
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
|
||||
// {
|
||||
|
|
@ -52,15 +69,14 @@ const activate = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" relative px4 py2 group>
|
||||
<div bg-base border="~ base" h10 rounded-full flex="~ row" items-center relative focus-within:box-shadow-outline>
|
||||
<div i-ri:search-2-line mx4 absolute pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||
<div ref="el" relative group>
|
||||
<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 pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
h-full
|
||||
ps-10
|
||||
rounded-full
|
||||
rounded-3
|
||||
w-full
|
||||
bg-transparent
|
||||
outline="focus:none"
|
||||
|
|
@ -74,13 +90,18 @@ const activate = () => {
|
|||
>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div p4 left-0 top-10 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 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-3 max-h-100 overflow-auto py2>
|
||||
<span v-if="query.length === 0" block text-center text-sm text-secondary>
|
||||
{{ t('search.search_desc') }}
|
||||
</span>
|
||||
<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>
|
||||
<div v-else>
|
||||
<SearchResultSkeleton />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import type { Account, Status } from 'masto'
|
||||
import type { RouteLocation } from 'vue-router'
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'account' | 'hashtag' | 'action' | 'status'
|
||||
to: string
|
||||
label?: string
|
||||
account?: Account
|
||||
status?: Status
|
||||
hashtag?: any
|
||||
action?: {
|
||||
label: string
|
||||
export type BuildResult<K extends keyof any, T> = {
|
||||
[P in K]: T
|
||||
} & {
|
||||
id: string
|
||||
type: K
|
||||
to: RouteLocation & {
|
||||
href: 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
|
||||
to?: string | Record<string, string>
|
||||
command?: boolean
|
||||
disabled?: boolean
|
||||
external?: true
|
||||
large?: true
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -33,10 +35,13 @@ useCommand({
|
|||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:disabled="disabled"
|
||||
:to="to"
|
||||
:external="external"
|
||||
exact-active-class="text-primary"
|
||||
:class="disabled ? 'op25 pointer-events-none ' : ''"
|
||||
block w-full group focus:outline-none
|
||||
:tabindex="disabled ? -1 : null"
|
||||
@click="to ? $scrollToTop() : undefined"
|
||||
>
|
||||
<div
|
||||
|
|
@ -51,7 +56,10 @@ useCommand({
|
|||
:class="$slots.description ? 'w-12 h-12' : ''"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div space-y-1>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { UpdateCredentialsParams } from 'masto'
|
||||
import { accountFieldIcons, getAccountFieldIcon } from '~/composables/masto/icons'
|
||||
|
||||
const { form } = defineModel<{
|
||||
form: {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,7 @@ const { account, link = true } = defineProps<{
|
|||
flex="~ col" min-w-0 md:flex="~ row gap-2" md:items-center
|
||||
text-link-rounded
|
||||
>
|
||||
<ContentRich
|
||||
font-bold line-clamp-1 ws-pre-wrap break-all
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
/>
|
||||
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||
<AccountHandle :account="account" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const reply = () => {
|
|||
color="text-green" hover="text-green" group-hover="bg-green/10"
|
||||
icon="i-ri:repeat-line"
|
||||
active-icon="i-ri:repeat-fill"
|
||||
:active="status.reblogged"
|
||||
:active="!!status.reblogged"
|
||||
:disabled="isLoading.reblogged"
|
||||
:command="command"
|
||||
@click="toggleReblog()"
|
||||
|
|
@ -88,7 +88,7 @@ const reply = () => {
|
|||
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
||||
icon="i-ri:heart-3-line"
|
||||
active-icon="i-ri:heart-3-fill"
|
||||
:active="status.favourited"
|
||||
:active="!!status.favourited"
|
||||
:disabled="isLoading.favourited"
|
||||
:command="command"
|
||||
@click="toggleFavourite()"
|
||||
|
|
@ -111,7 +111,7 @@ const reply = () => {
|
|||
color="text-yellow" hover="text-yellow" group-hover="bg-yellow/10"
|
||||
icon="i-ri:bookmark-line"
|
||||
active-icon="i-ri:bookmark-fill"
|
||||
:active="status.bookmarked"
|
||||
:active="!!status.bookmarked"
|
||||
:disabled="isLoading.bookmarked"
|
||||
:command="command"
|
||||
@click="toggleBookmark()"
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ async function editStatus() {
|
|||
|
||||
<template #popper>
|
||||
<div flex="~ col">
|
||||
<template v-if="isZenMode">
|
||||
<template v-if="userSettings.zenMode">
|
||||
<CommonDropdownItem
|
||||
:text="$t('action.reply')"
|
||||
icon="i-ri:chat-3-line"
|
||||
|
|
@ -186,9 +186,8 @@ async function editStatus() {
|
|||
@click="toggleMute()"
|
||||
/>
|
||||
|
||||
<NuxtLink :to="status.url" external target="_blank">
|
||||
<NuxtLink v-if="status.url" :to="status.url" external target="_blank">
|
||||
<CommonDropdownItem
|
||||
v-if="status.url"
|
||||
:text="$t('menu.open_in_original_site')"
|
||||
icon="i-ri:arrow-right-up-line"
|
||||
:command="command"
|
||||
|
|
|
|||
|
|
@ -35,9 +35,12 @@ const aspectRatio = computed(() => {
|
|||
})
|
||||
|
||||
const objectPosition = computed(() => {
|
||||
return [attachment.meta?.focus?.x, attachment.meta?.focus?.y]
|
||||
.map(v => v ? `${v * 100}%` : '50%')
|
||||
.join(' ')
|
||||
const focusX = attachment.meta?.focus?.x || 0
|
||||
const focusY = attachment.meta?.focus?.y || 0
|
||||
const x = ((focusX / 2) + 0.5) * 100
|
||||
const y = ((focusY / -2) + 0.5) * 100
|
||||
|
||||
return `${x}% ${y}%`
|
||||
})
|
||||
|
||||
const typeExtsMap = {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import type { Status, StatusEdit } from 'masto'
|
||||
|
||||
const { status, withAction = true } = defineProps<{
|
||||
status: Status
|
||||
status: Status | StatusEdit
|
||||
withAction?: boolean
|
||||
}>()
|
||||
|
||||
const { translation } = useTranslation(status)
|
||||
</script>
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ const { translation } = useTranslation(status)
|
|||
class="line-compact"
|
||||
:content="status.content"
|
||||
:emojis="status.emojis"
|
||||
:lang="status.language"
|
||||
:lang="'language' in status && status.language"
|
||||
/>
|
||||
<div v-else />
|
||||
<template v-if="translation.visible">
|
||||
|
|
|
|||
|
|
@ -90,13 +90,14 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
ref="el"
|
||||
relative flex flex-col gap-1 pl-3 pr-4 pt-1
|
||||
class="pb-1.5"
|
||||
:class="{ 'hover:bg-active': hover, 'border-t border-base': newer && !directReply }"
|
||||
:class="{ 'hover:bg-active': hover }"
|
||||
tabindex="0"
|
||||
focus:outline-none focus-visible:ring="2 primary"
|
||||
:lang="status.language ?? undefined"
|
||||
@click="onclick"
|
||||
@keydown.enter="onclick"
|
||||
>
|
||||
<div v-if="newer && !directReply" w-auto h-1px bg-border />
|
||||
<div flex justify-between>
|
||||
<slot name="meta">
|
||||
<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 v-else />
|
||||
</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 flex gap-3 :class="{ 'text-secondary': faded }">
|
||||
<div relative>
|
||||
|
|
@ -125,7 +126,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
<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 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' : ''" />
|
||||
</div>
|
||||
<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 />
|
||||
<div flex>
|
||||
<CommonTooltip :content="createdAt">
|
||||
|
|
@ -154,7 +155,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
</div>
|
||||
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
||||
<div>
|
||||
<StatusActions v-if="(actions !== false && !isZenMode)" :status="status" />
|
||||
<StatusActions v-if="(actions !== false && !userSettings.zenMode)" :status="status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import { statusVisibilities } from '~/composables/masto/icons'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
status: Status
|
||||
|
|
@ -55,7 +54,12 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
<div v-if="status.application?.name">
|
||||
·
|
||||
</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 }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import type { Status, StatusEdit } from 'masto'
|
||||
|
||||
const { status } = defineProps<{
|
||||
status: Status
|
||||
status: Status | StatusEdit
|
||||
fullSize?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const isSquare = $computed(() => (
|
|||
))
|
||||
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';
|
||||
const cardTypeIconMap: Record<CardType, string> = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import type { Card } from 'masto'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
card: Card
|
||||
/** When it is root card in the list, not appear as a child card */
|
||||
root?: boolean
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const account = isSelf ? computed(() => status.account) : useAccountById(status.
|
|||
<AccountInlineInfo v-else :account="account" :link="false" mx-0.5 />
|
||||
</template>
|
||||
</template>
|
||||
<div i-ph:chats-fill text-primary text-lg />
|
||||
<div i-ri:question-answer-line text-secondary-light text-lg />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,10 +22,7 @@ const { edit } = defineProps<{
|
|||
{{ edit.spoilerText }}
|
||||
</template>
|
||||
<StatusBody :status="edit" />
|
||||
<StatusMedia
|
||||
v-if="edit.mediaAttachments.length"
|
||||
:status="edit"
|
||||
/>
|
||||
<StatusMedia v-if="edit.mediaAttachments.length" :status="edit" />
|
||||
</StatusSpoiler>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
const paginator = useMasto().timelines.iterateHome()
|
||||
const stream = useMasto().stream.streamUser()
|
||||
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
|
||||
|
|
|
|||
|
|
@ -2,21 +2,26 @@
|
|||
// @ts-expect-error missing types
|
||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||
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[]>
|
||||
stream?: Promise<WsEvents>
|
||||
context?: FilterContext
|
||||
account?: Account
|
||||
preprocess?: (items: any[]) => any[]
|
||||
}>()
|
||||
|
||||
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>
|
||||
|
||||
<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 }">
|
||||
<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) } }) }}
|
||||
|
|
@ -32,5 +37,18 @@ const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirt
|
|||
<StatusCard :status="item" :context="context" :older="older" :newer="newer" />
|
||||
</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>
|
||||
</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>
|
||||
<VDropdown :distance="0" placement="top-start">
|
||||
<button btn-action-icon :aria-label="$t('action.switch_account')">
|
||||
<!-- TODO -->
|
||||
<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>
|
||||
</button>
|
||||
<template #popper="{ hide }">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const masto = useMasto()
|
|||
hover="filter-none op100"
|
||||
@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>
|
||||
G
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<p text-sm text-secondary>
|
||||
{{ $t('user.sign_in_desc') }}
|
||||
</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') }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const masto = useMasto()
|
|||
aria-label="Switch user"
|
||||
@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" />
|
||||
<div flex-auto />
|
||||
<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 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({
|
||||
scope: 'Navigation',
|
||||
|
||||
|
|
@ -285,10 +271,10 @@ export const provideGlobalCommands = () => {
|
|||
scope: 'Preferences',
|
||||
|
||||
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() {
|
||||
toggleZenMode()
|
||||
userSettings.value.zenMode = !userSettings.value.zenMode
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// @unimport-disable
|
||||
import type { Emoji } from 'masto'
|
||||
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 { emojiRegEx, getEmojiAttributes } from '../config/emojis'
|
||||
|
||||
|
|
@ -19,53 +19,43 @@ export function decodeHtml(text: string) {
|
|||
* with interop of custom emojis and inline Markdown syntax
|
||||
*/
|
||||
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) {
|
||||
// handle code blocks
|
||||
processed = processed
|
||||
// Handle code blocks
|
||||
html = html
|
||||
.replace(/>(```|~~~)(\w*)([\s\S]+?)\1/g, (_1, _2, lang, raw) => {
|
||||
const code = htmlToText(raw)
|
||||
const classes = lang ? ` class="language-${lang}"` : ''
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace unicode emojis with locally hosted images
|
||||
*/
|
||||
export function replaceUnicodeEmoji(html: string) {
|
||||
return findAndReplaceEmojisInText(emojiRegEx, html, (match) => {
|
||||
const attrs = getEmojiAttributes(match)
|
||||
return `<img src="${attrs.src}" alt="${attrs.alt}" class="${attrs.class}" />`
|
||||
}) || html
|
||||
// A tree transform function takes an ultrahtml Node object and returns
|
||||
// new content that will replace the given node in the tree.
|
||||
// Returning a null removes the node from the tree.
|
||||
// Strings get converted to text nodes.
|
||||
// The input node's children have been transformed before the node itself
|
||||
// gets transformed.
|
||||
type Transform = (node: Node) => (Node | string)[] | Node | string | null
|
||||
|
||||
// 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 { 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 mediaPreviewIndex = ref(0)
|
||||
|
|
@ -11,7 +11,6 @@ export const dialogDraftKey = ref<string>()
|
|||
export const commandPanelInput = ref('')
|
||||
|
||||
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 isPublishDialogOpen = ref(false)
|
||||
|
|
@ -22,8 +21,6 @@ export const isCommandPanelOpen = ref(false)
|
|||
|
||||
export const lastPublishDialogStatus = ref<Status | null>(null)
|
||||
|
||||
export const toggleZenMode = useToggle(isZenMode)
|
||||
|
||||
export function openSigninDialog() {
|
||||
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) {
|
||||
mediaPreviewList.value = attachments
|
||||
mediaPreviewIndex.value = index
|
||||
isMediaPreviewOpen.value = true
|
||||
|
||||
history.pushState({
|
||||
...history.state,
|
||||
mediaPreview: true,
|
||||
mediaPreviewList: JSON.stringify(attachments),
|
||||
mediaPreviewIndex: index,
|
||||
}, '')
|
||||
}
|
||||
|
||||
export function closeMediaPreview() {
|
||||
isMediaPreviewOpen.value = false
|
||||
history.back()
|
||||
}
|
||||
|
||||
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>)
|
||||
|
||||
// @ts-expect-error we need to fix types: just suppress it for now
|
||||
const handlers: Handlers = {
|
||||
onPinch({ offset: [d] }) {
|
||||
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
|
||||
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
|
||||
Alipay: 'i-ri:alipay-fill',
|
||||
Bilibili: 'i-ri:bilibili-fill',
|
||||
Alipay: 'i-ri:alipay-line',
|
||||
Bilibili: 'i-ri:bilibili-line',
|
||||
Birth: 'i-ri:calendar-line',
|
||||
Blog: 'i-ri:newspaper-line',
|
||||
City: 'i-ri:map-pin-2-line',
|
||||
Dingding: 'i-ri:dingding-fill',
|
||||
Discord: 'i-ri:discord-fill',
|
||||
Douban: 'i-ri:douban-fill',
|
||||
Facebook: 'i-ri:facebook-fill',
|
||||
GitHub: 'i-ri:github-fill',
|
||||
GitLab: 'i-ri:gitlab-fill',
|
||||
Dingding: 'i-ri:dingding-line',
|
||||
Discord: 'i-ri:discord-line',
|
||||
Douban: 'i-ri:douban-line',
|
||||
Facebook: 'i-ri:facebook-line',
|
||||
GitHub: 'i-ri:github-line',
|
||||
GitLab: 'i-ri:gitlab-line',
|
||||
Home: 'i-ri:home-2-line',
|
||||
Instagram: 'i-ri:instagram-line',
|
||||
Joined: 'i-ri:user-add-line',
|
||||
Language: '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',
|
||||
Mastodon: 'i-ri:mastodon-line',
|
||||
Medium: 'i-ri:medium-fill',
|
||||
Patreon: 'i-ri:patreon-fill',
|
||||
PayPal: 'i-ri:paypal-fill',
|
||||
PlayStation: 'i-ri:playstation-fill',
|
||||
Medium: 'i-ri:medium-line',
|
||||
Patreon: 'i-ri:patreon-line',
|
||||
PayPal: 'i-ri:paypal-line',
|
||||
PlayStation: 'i-ri:playstation-line',
|
||||
Portfolio: 'i-ri:link',
|
||||
QQ: 'i-ri:qq-fill',
|
||||
Pronouns: 'i-ri:contacts-line',
|
||||
QQ: 'i-ri:qq-line',
|
||||
Site: 'i-ri:link',
|
||||
Sponsors: 'i-ri:heart-3-line',
|
||||
Spotify: 'i-ri:spotify-fill',
|
||||
Steam: 'i-ri:steam-fill',
|
||||
Switch: 'i-ri:switch-fill',
|
||||
Telegram: 'i-ri:telegram-fill',
|
||||
Tumblr: 'i-ri:tumblr-fill',
|
||||
Spotify: 'i-ri:spotify-line',
|
||||
Steam: 'i-ri:steam-line',
|
||||
Switch: 'i-ri:switch-line',
|
||||
Telegram: 'i-ri:telegram-line',
|
||||
Tumblr: 'i-ri:tumblr-line',
|
||||
Twitch: 'i-ri:twitch-line',
|
||||
Twitter: 'i-ri:twitter-line',
|
||||
Website: 'i-ri:link',
|
||||
WeChat: 'i-ri:wechat-fill',
|
||||
Weibo: 'i-ri:weibo-fill',
|
||||
Xbox: 'i-ri:xbox-fill',
|
||||
WeChat: 'i-ri:wechat-line',
|
||||
Weibo: 'i-ri:weibo-line',
|
||||
Xbox: 'i-ri:xbox-line',
|
||||
YouTube: 'i-ri:youtube-line',
|
||||
Zhihu: 'i-ri:zhihu-fill',
|
||||
Zhihu: 'i-ri:zhihu-line',
|
||||
}).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
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 builtinDraftKeys = [
|
||||
'dialog',
|
||||
'home',
|
||||
]
|
||||
|
||||
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
|
||||
const {
|
||||
attachments = [],
|
||||
|
|
@ -21,7 +26,6 @@ export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & O
|
|||
return {
|
||||
attachments,
|
||||
initialText,
|
||||
|
||||
params: {
|
||||
status: status || '',
|
||||
inReplyToId,
|
||||
|
|
@ -30,6 +34,7 @@ export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & O
|
|||
spoilerText: spoilerText || '',
|
||||
language: language || 'en',
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,10 +83,11 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
|
|||
}
|
||||
|
||||
export function useDraft(
|
||||
draftKey: string,
|
||||
draftKey?: string,
|
||||
initial: () => Draft = () => getDefaultDraft({}),
|
||||
) {
|
||||
const draft = computed({
|
||||
const draft = draftKey
|
||||
? computed({
|
||||
get() {
|
||||
if (!currentUserDrafts.value[draftKey])
|
||||
currentUserDrafts.value[draftKey] = initial()
|
||||
|
|
@ -91,12 +97,13 @@ export function useDraft(
|
|||
currentUserDrafts.value[draftKey] = val
|
||||
},
|
||||
})
|
||||
: ref(initial())
|
||||
|
||||
const isEmpty = computed(() => isEmptyDraft(draft.value))
|
||||
|
||||
onUnmounted(async () => {
|
||||
// Remove draft if it's empty
|
||||
if (isEmpty.value) {
|
||||
if (isEmpty.value && draftKey) {
|
||||
await nextTick()
|
||||
delete currentUserDrafts.value[draftKey]
|
||||
}
|
||||
|
|
@ -117,3 +124,12 @@ export function directMessageUser(account: Account) {
|
|||
visibility: 'direct',
|
||||
}), 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 {
|
||||
translatedText: string
|
||||
|
|
@ -24,15 +24,18 @@ export async function translateText(text: string, from?: string | null, to?: str
|
|||
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))
|
||||
translations.set(status, reactive({ visible: false, text: '' }))
|
||||
|
||||
const translation = translations.get(status)!
|
||||
|
||||
async function toggle() {
|
||||
if (!('language' in status))
|
||||
return
|
||||
|
||||
if (!translation.text)
|
||||
translation.text = await translateText(status.content, status.language)
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Paginator, WsEvents } from 'masto'
|
||||
import { useDeactivated } from './lifecycle'
|
||||
import type { PaginatorState } from '~/types'
|
||||
|
||||
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 { buildInfo } from 'virtual:build-info'
|
||||
import type { LocaleObject } from '#i18n'
|
||||
|
|
@ -7,28 +5,6 @@ import type { LocaleObject } from '#i18n'
|
|||
export function setupPageHeader() {
|
||||
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) => {
|
||||
acc[l.code!] = l.dir ?? 'auto'
|
||||
return acc
|
||||
|
|
@ -46,6 +22,12 @@ export function setupPageHeader() {
|
|||
titleTemplate += ` (${buildInfo.env})`
|
||||
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 type { Ref } from 'vue'
|
||||
import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { HashtagSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { CodeBlockShiki } from './tiptap/shiki'
|
||||
import { CustomEmoji } from './tiptap/custom-emoji'
|
||||
import { Emoji } from './tiptap/emoji'
|
||||
|
|
@ -54,9 +54,9 @@ export function useTiptap(options: UseTiptapOptions) {
|
|||
suggestion: MentionSuggestion,
|
||||
}),
|
||||
Mention
|
||||
.extend({ name: 'hastag' })
|
||||
.extend({ name: 'hashtag' })
|
||||
.configure({
|
||||
suggestion: HashSuggestion,
|
||||
suggestion: HashtagSuggestion,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder.value,
|
||||
|
|
|
|||
|
|
@ -2,9 +2,39 @@ import {
|
|||
Node,
|
||||
mergeAttributes,
|
||||
nodeInputRule,
|
||||
nodePasteRule,
|
||||
} from '@tiptap/core'
|
||||
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({
|
||||
name: 'em-emoji',
|
||||
|
||||
|
|
@ -50,26 +80,10 @@ export const Emoji = Node.create({
|
|||
},
|
||||
|
||||
addInputRules() {
|
||||
const inputRule = nodeInputRule({
|
||||
find: emojiRegEx as RegExp,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [native] = match
|
||||
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,
|
||||
]
|
||||
return createEmojiRule(nodeInputRule, this.type)
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return createEmojiRule(nodePasteRule, this.type)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import tippy from 'tippy.js'
|
|||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||
import { PluginKey } from 'prosemirror-state'
|
||||
import type { Component } from 'vue'
|
||||
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
||||
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
|
||||
|
||||
export const MentionSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('mention'),
|
||||
|
|
@ -17,29 +19,32 @@ export const MentionSuggestion: Partial<SuggestionOptions> = {
|
|||
|
||||
return results.value.accounts
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
render: createSuggestionRenderer(TiptapMentionList),
|
||||
}
|
||||
|
||||
export const HashSuggestion: Partial<SuggestionOptions> = {
|
||||
export const HashtagSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('hashtag'),
|
||||
char: '#',
|
||||
items({ query }) {
|
||||
// TODO: query
|
||||
return [
|
||||
'TODO HASH QUERY',
|
||||
].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5)
|
||||
async items({ query }) {
|
||||
if (query.length === 0)
|
||||
return []
|
||||
|
||||
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 () => {
|
||||
let component: VueRenderer
|
||||
let renderer: VueRenderer
|
||||
let popup: Instance
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
component = new VueRenderer(TiptapMentionList, {
|
||||
renderer = new VueRenderer(component, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
|
@ -50,7 +55,7 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
content: renderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
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
|
||||
onBeforeUpdate: (props) => {
|
||||
component.updateProps({ ...props, isPending: true })
|
||||
renderer.updateProps({ ...props, isPending: true })
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({ ...props, isPending: false })
|
||||
renderer.updateProps({ ...props, isPending: false })
|
||||
|
||||
if (!props.clientRect)
|
||||
return
|
||||
|
|
@ -79,12 +84,12 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props.event)
|
||||
return renderer?.ref?.onKeyDown(props.event)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
component?.destroy()
|
||||
renderer?.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,37 @@ export const currentUserHandle = computed(() =>
|
|||
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 characterLimit = computed(() => currentInstance.value?.configuration.statuses.maxCharacters ?? DEFAULT_POST_CHARS_LIMIT)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { ComponentInternalInstance } 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
|
||||
|
|
@ -28,3 +33,13 @@ export function onReactivated(hook: Function, target?: ComponentInternalInstance
|
|||
}, target)
|
||||
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
|
||||
? 'preview'
|
||||
: branch === 'main'
|
||||
? 'main'
|
||||
? 'canary'
|
||||
: 'release'
|
||||
return { commit, branch, env } as const
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ const locales: LocaleObjectData[] = [
|
|||
file: 'ja-JP.json',
|
||||
name: '日本語',
|
||||
},
|
||||
{
|
||||
code: 'nl-NL',
|
||||
file: 'nl-NL.json',
|
||||
name: 'Nederlands',
|
||||
},
|
||||
{
|
||||
code: 'es-ES',
|
||||
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