Merge branch 'main' into feat/387-expand-mastodon-links

This commit is contained in:
Ayo 2023-01-06 18:43:00 +01:00
commit 05bbd6ba4d
180 changed files with 3257 additions and 1451 deletions

View file

@ -1 +0,0 @@
GitHub Actions is temporary disabled as we are reaching the usage limit as a private repo. Tests have been moved to Netlify pipeline as an workaround. We shall recover this once we open up.

View file

@ -7,6 +7,7 @@ on:
pull_request:
branches:
- main
workflow_dispatch: {}
jobs:
ci:
@ -17,7 +18,7 @@ jobs:
- run: corepack enable
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
cache: pnpm
- name: 📦 Install dependencies

34
.vscode/settings.json vendored
View file

@ -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"
}

View file

@ -29,7 +29,7 @@ git checkout -b my-new-branch
### Running PWA on dev server
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:pwa:mocked` to start dev server with `@elkdev@universeodon.com` user.
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.

View file

@ -12,4 +12,13 @@ const key = computed(() => `${currentUser.value?.server ?? currentServer.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>

View file

@ -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"

View file

@ -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>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Account, Field } from 'masto'
import { getAccountFieldIcon } from '~/composables/masto/icons'
const { account } = defineProps<{
account: Account
@ -118,7 +119,9 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
</div>
<div v-if="iconFields.length" flex="~ wrap gap-4">
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" items-center>
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
<CommonTooltip :content="getFieldIconTitle(field.name)">
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
</CommonTooltip>
<ContentRich text-sm filter-saturate-0 :content="field.value" :emojis="account.emojis" />
</div>
</div>

View file

@ -5,6 +5,7 @@ const { account, as = 'div' } = defineProps<{
account: Account
as?: string
hoverCard?: boolean
square?: boolean
}>()
defineOptions({
@ -17,9 +18,9 @@ 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

View file

@ -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>

View file

@ -37,5 +37,5 @@ const tabs = $computed(() => [
</script>
<template>
<CommonRouteTabs force :options="tabs" prevent-scroll-top command />
<CommonRouteTabs force replace :options="tabs" prevent-scroll-top command border="base b" />
</template>

View file

@ -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()
@ -235,7 +247,7 @@ const onKeyDown = (e: KeyboardEvent) => {
<!-- Footer -->
<div class="flex items-center px-3 py-1 text-xs">
<div i-ri:lightbulb-flash-line /> Tip: Use
<!-- <CommandKey name="Ctrl+K" /> to search, -->
<CommandKey name="Ctrl+K" /> to search,
<CommandKey name="Ctrl+/" /> to activate command mode.
</div>
</div>

View file

@ -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')

View file

@ -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 })
},
})

View 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>

View file

@ -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>

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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 { Paginator, WsEvents } from 'masto'
import type { Account, Paginator, WsEvents } from 'masto'
const {
paginator,
@ -11,6 +11,7 @@ const {
virtualScroller = false,
eventType = 'update',
preprocess,
isAccountTimeline,
} = defineProps<{
paginator: Paginator<any, any[]>
keyProp?: string
@ -18,6 +19,7 @@ const {
stream?: Promise<WsEvents>
eventType?: 'notification' | 'update'
preprocess?: (items: any[]) => any[]
isAccountTimeline?: boolean
}>()
defineSlots<{
@ -34,6 +36,16 @@ defineSlots<{
loading: {}
}>()
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>
@ -72,8 +84,14 @@ 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>
{{ $t('common.end_of_list') }}
<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>
{{ $t('common.end_of_list') }}
</template>
</div>
<div v-else-if="state === 'error'" p5 text-secondary>
{{ $t('common.error') }}: {{ error }}

View file

@ -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>

View file

@ -40,12 +40,12 @@ useCommands(() => command
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
tabindex="1"
hover:bg-active transition-100
exact-active-class="children:(text-secondary !border-primary !op100)"
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>

View file

@ -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>

View file

@ -1,14 +1,16 @@
<script setup lang="ts">
import { dropdownContextKey } from './ctx'
import { InjectionKeyDropdownContext } from '~/constants/symbols'
defineProps<{
placement?: string
autoBoundaryMaxSize?: boolean
}>()
const dropdown = $ref<any>()
const colorMode = useColorMode()
const hide = () => dropdown.hide()
provide(dropdownContextKey, {
provide(InjectionKeyDropdownContext, {
hide,
})
@ -18,7 +20,7 @@ defineExpose({
</script>
<template>
<VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'">
<VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'" :auto-boundary-max-size="autoBoundaryMaxSize">
<slot />
<template #popper="scope">
<slot name="popper" v-bind="scope" />

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import { dropdownContextKey } from './ctx'
const props = defineProps<{
text?: string
description?: string
@ -10,7 +8,7 @@ const props = defineProps<{
}>()
const emit = defineEmits(['click'])
const { hide } = inject(dropdownContextKey, undefined) || {}
const { hide } = useDropdownContext() || {}
const el = ref<HTMLDivElement>()

View file

@ -1,5 +0,0 @@
import type { InjectionKey } from 'vue'
export const dropdownContextKey: InjectionKey<{
hide: () => void
}> = Symbol('dropdownContextKey')

View file

@ -22,15 +22,17 @@ const emit = defineEmits<{
{{ $t('help.desc_para2') }}
</p>
<p>
Before that, if you'd like to help with testing, giving feedback, or contributing, <a font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
reach out to us on Mastodon
</a> and get involved.
{{ $t('help.desc_para4') }}
<a font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
{{ $t('help.desc_para5') }}
</a>
{{ $t('help.desc_para6') }}
</p>
{{ $t('help.desc_para3') }}
<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>

View file

@ -14,7 +14,7 @@ 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
@ -37,6 +37,7 @@ defineProps<{
</div>
<slot name="header" />
</div>
<div hidden xl:block h-6 />
<slot />
</div>
</template>

View file

@ -43,7 +43,7 @@ const handlePublishClose = () => {
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
<UserSignIn />
</ModalDialog>
<ModalDialog v-model="isPreviewHelpOpen" max-w-125>
<ModalDialog v-model="isPreviewHelpOpen" keep-alive max-w-125>
<HelpPreview @close="closePreviewHelp()" />
</ModalDialog>
<ModalDialog
@ -53,6 +53,7 @@ const handlePublishClose = () => {
>
<!-- This `w-0` style is used to avoid overflow problems in flex layoutsso 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()" />

View file

@ -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()
@ -66,6 +65,8 @@ const { activate } = useFocusTrap(elDialogRoot, {
allowOutsideClick: true,
clickOutsideDeactivates: true,
escapeDeactivates: true,
preventScroll: true,
returnFocusOnDeactivate: true,
})
defineExpose({

View file

@ -1,32 +1,14 @@
<script setup lang="ts">
import { useImageGesture } from '~/composables/gestures'
const emit = defineEmits(['close'])
const img = ref()
const locked = useScrollLock(document.body)
// Use to avoid strange error when directlying assigning to v-model on ModelMediaPreviewCarousel
const index = mediaPreviewIndex
const current = computed(() => mediaPreviewList.value[mediaPreviewIndex.value])
const hasNext = computed(() => mediaPreviewIndex.value < mediaPreviewList.value.length - 1)
const hasPrev = computed(() => mediaPreviewIndex.value > 0)
useImageGesture(img, {
hasNext,
hasPrev,
onNext() {
if (hasNext.value)
mediaPreviewIndex.value++
},
onPrev() {
if (hasPrev.value)
mediaPreviewIndex.value--
},
})
// stop global zooming
useEventListener('wheel', (evt) => {
if (evt.ctrlKey && (evt.deltaY < 0 || evt.deltaY > 0))
evt.preventDefault()
}, { passive: false })
const hasNext = computed(() => index.value < mediaPreviewList.value.length - 1)
const hasPrev = computed(() => index.value > 0)
const keys = useMagicKeys()
@ -35,12 +17,12 @@ whenever(keys.arrowRight, next)
function next() {
if (hasNext.value)
mediaPreviewIndex.value++
index.value++
}
function prev() {
if (hasPrev.value)
mediaPreviewIndex.value--
index.value--
}
function onClick(e: MouseEvent) {
@ -49,30 +31,31 @@ function onClick(e: MouseEvent) {
if (!el)
emit('close')
}
onMounted(() => locked.value = true)
onUnmounted(() => locked.value = false)
</script>
<template>
<div relative h-full w-full flex pt-12 @click="onClick">
<div relative h-full w-full flex pt-12 w-100vh @click="onClick">
<button
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1 z5
:title="$t('action.next')" @click="next"
>
<div i-ri:arrow-right-s-line text-white />
</button>
<button
v-if="hasPrev" pointer-events-auto btn-action-icon bg="black/20" aria-label="action.next"
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" left-1
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" left-1 z5
:title="$t('action.prev')" @click="prev"
>
<div i-ri:arrow-left-s-line text-white />
</button>
<img
ref="img"
:src="current.url || current.previewUrl"
:alt="current.description || ''"
max-h-full max-w-full ma
>
<div flex flex-row items-center mxa>
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
</div>
<div absolute top-0 w-full flex justify-between>
<button
@ -83,7 +66,7 @@ function onClick(e: MouseEvent) {
</button>
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
{{ mediaPreviewIndex + 1 }} / {{ mediaPreviewList.length }}
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import { SwipeDirection } from '@vueuse/core'
import { useReducedMotion } from '@vueuse/motion'
import type { Attachment } from 'masto'
const { media = [], threshold = 20 } = defineProps<{
media?: Attachment[]
threshold?: number
}>()
const emit = defineEmits<{
(event: 'close'): void
}>()
const { modelValue } = defineModel<{
modelValue: number
}>()
const target = ref()
const animateTimeout = useTimeout(10)
const reduceMotion = useReducedMotion()
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
const { width, height } = useElementSize(target)
const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
threshold: 5,
passive: false,
onSwipeEnd(e, direction) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold)
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) > 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) > threshold)
emit('close')
},
})
const distanceX = computed(() => {
if (width.value === 0)
return 0
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
return modelValue.value * 100 * -1
return (lengthX.value / width.value) * 100 * -1 + (modelValue.value * 100) * -1
})
const distanceY = computed(() => {
if (height.value === 0 || !isSwiping.value || direction.value !== SwipeDirection.UP)
return 0
return (lengthY.value / height.value) * 100 * -1
})
</script>
<template>
<div ref="target" flex flex-row max-h-full max-w-full overflow-hidden>
<div flex :style="{ transform: `translateX(${distanceX}%) translateY(${distanceY}%)`, transition: isSwiping ? 'none' : canAnimate ? 'all 0.5s ease' : 'none' }">
<div v-for="item in media" :key="item.id" p4 select-none w-full flex-shrink-0 flex flex-col place-items-center>
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
</div>
</div>
</div>
</template>

View file

@ -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>

View file

@ -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"
@ -86,17 +82,6 @@ onBeforeUnmount(() => {
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl me-4 !align-middle" />
{{ colorMode.value === 'light' ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
</button>
<NuxtLink
flex flex-row items-center
block px-5 py-2 focus-blue w-full
text-sm text-base capitalize text-left whitespace-nowrap
transition-colors duration-200 transform
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
to="/settings"
>
<span class="i-ri:settings-2-line flex-shrink-0 text-xl me-4 !align-middle" />
{{ $t('nav.settings') }}
</NuxtLink>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import buildInfo from 'virtual:build-info'
import { buildInfo } from 'virtual:build-info'
const timeAgoOptions = useTimeAgoOptions()
@ -22,35 +22,23 @@ 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()"
/>
</CommonTooltip>
<CommonTooltip :content="$t('nav.settings')">
<NuxtLink
flex
text-lg
to="/settings"
i-ri:settings-4-line
:aria-label="$t('nav.settings')"
@click="userSettings.zenMode = !userSettings.zenMode"
/>
</CommonTooltip>
</div>
<div>
<button cursor-pointer hover:underline @click="openPreviewHelp">
{{ $t('nav.show_intro') }}
</button>
</div>
<div>{{ $t('app_desc_short') }}</div>
<div>
<i18n-t keypath="nav.built_at">
<i18n-t v-if="isHydrated" keypath="nav.built_at">
<time :datetime="String(buildTimeDate)" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
</i18n-t>
<template v-if="buildInfo.version">
&middot;
v{{ buildInfo.version }}
</template>
<span v-else>
{{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }}
</span>
&middot;
<!-- TODO click version to show changelog -->
<span v-if="buildInfo.env === 'release'">v{{ buildInfo.version }}</span>
<span v-else>{{ buildInfo.env }}</span>
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
&middot;
<NuxtLink
@ -64,7 +52,15 @@ function toggleDark() {
</template>
</div>
<div>
<a href="/m.webtoo.ls/@elk" target="_blank">Mastodon</a> &middot; <a href="https://chat.elk.zone" target="_blank">Discord</a> &middot; <a href="https://github.com/elk-zone" target="_blank">GitHub</a>
<NuxtLink cursor-pointer hover:underline to="/settings/about">
{{ $t('settings.about.label') }}
</NuxtLink>
&middot;
<a href="/m.webtoo.ls/@elk" target="_blank">Mastodon</a>
&middot;
<a href="https://chat.elk.zone" target="_blank">Discord</a>
&middot;
<a href="https://github.com/elk-zone" target="_blank">GitHub</a>
</div>
</footer>
</template>

View file

@ -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,15 +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" lg: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('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>

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import { warn } from 'vue'
const props = withDefaults(defineProps<{
text?: string
icon: string
@ -31,14 +29,12 @@ useCommand({
})
let activeClass = $ref('text-primary')
watch(isMastoInitialised, 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'
}
onMastoInit(async () => {
// 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
@ -60,17 +56,17 @@ 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
lg="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 />
</slot>
<slot>
<span block sm:hidden lg:block>{{ text }}</span>
<span block sm:hidden xl:block>{{ text }}</span>
</slot>
</div>
</CommonTooltip>

View file

@ -1,23 +1,31 @@
<script setup lang="ts">
const env = useRuntimeConfig().public.env
const sub = env === 'local' ? 'dev' : env === 'staging' ? 'preview' : 'alpha'
import { buildInfo } from 'virtual:build-info'
const { env } = buildInfo
</script>
<template>
<!-- Use external to force refresh page and jump to top of timeline -->
<NuxtLink
flex items-end gap-2
w-fit
py2 px-2 lg:px-3
text-2xl hover:bg-active
focus-visible:ring="2 current"
rounded-full
to="/"
external
>
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 lg:h-10 class="rtl-flip">
<div hidden lg:block>
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ sub }}</sup>
<div flex justify-between>
<NuxtLink
flex items-end gap-4
py2 px-5
text-2xl
focus-visible:ring="2 current"
to="/"
external
>
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
<div hidden xl:block>
{{ $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
@click="$router.go(-1)"
>
<div i-ri:arrow-left-line class="rtl-flip" btn-text />
</NuxtLink>
</div>
</NuxtLink>
</div>
</template>

View file

@ -7,6 +7,7 @@
h-8
w-8
:draggable="false"
square
/>
</div>
@ -14,7 +15,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>

View file

@ -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>

View file

@ -1,5 +1,11 @@
<script setup lang="ts">
// type used in <template>
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { Notification, Paginator, WsEvents } from 'masto'
// type used in <template>
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { GroupedLikeNotifications } from '~/types'
import type { GroupedAccountLike, NotificationSlot } from '~/types'
const { paginator, stream } = defineProps<{
@ -118,12 +124,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"
/>

View file

@ -2,7 +2,7 @@
import NotificationSubscribePushNotificationError
from '~/components/notification/NotificationSubscribePushNotificationError.vue'
defineProps<{ show: boolean }>()
defineProps<{ show?: boolean }>()
const {
pushNotificationData,
@ -71,12 +71,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 +103,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 +147,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 +159,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 +181,16 @@ onActivated(() => (busy = false))
</NotificationEnablePushNotification>
</template>
</template>
<p v-else role="alert" aria-labelledby="notifications-unsupported">
{{ $t('notification.settings.unsupported') }}
</p>
<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 +207,5 @@ onActivated(() => (busy = false))
</Transition>
</template>
</NotificationEnablePushNotification>
</div>
</section>
</template>

View file

@ -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 />

View file

@ -7,16 +7,16 @@ const disabledVisual = computed(() => isMastoInitialised.value && !currentUser.v
<button
flex="~ gap2 center"
w-9 h-9 py2
lg="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"
@click="openPublishDialog()"
>
<div i-ri:quill-pen-line />
<span hidden lg:block>{{ $t('action.compose') }}</span>
<span hidden xl:block>{{ $t('action.compose') }}</span>
</button>
</template>

View file

@ -44,15 +44,17 @@ const hideEmojiPicker = () => {
</script>
<template>
<VDropdown
@apply-show="openEmojiPicker()"
@apply-hide="hideEmojiPicker()"
>
<button btn-action-icon :title="$t('tooltip.emoji')">
<div i-ri:emotion-line />
</button>
<template #popper>
<div ref="el" min-w-10 min-h-10 />
</template>
</VDropdown>
<CommonTooltip content="Add emojis">
<VDropdown
auto-boundary-max-size
@apply-show="openEmojiPicker()"
@apply-hide="hideEmojiPicker()"
>
<slot />
<template #popper>
<div ref="el" min-w-10 min-h-10 />
</template>
</VDropdown>
</CommonTooltip>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import ISO6391 from 'iso-639-1'
import Fuse from 'fuse.js'
let { modelValue } = $defineModel<{
modelValue: string
}>()
const { t } = useI18n()
const languageKeyword = $ref('')
const languageList: {
code: string
nativeName: string
name: string
}[] = ISO6391.getAllCodes().map(code => ({
code,
nativeName: ISO6391.getNativeName(code),
name: ISO6391.getName(code),
}))
const fuse = new Fuse(languageList, {
keys: ['code', 'nativeName', 'name'],
shouldSort: true,
})
const languages = $computed(() =>
languageKeyword.trim()
? fuse.search(languageKeyword).map(r => r.item)
: [...languageList].sort(({ code: a }, { code: b }) => {
return a === modelValue ? -1 : b === modelValue ? 1 : a.localeCompare(b)
}),
)
function chooseLanguage(language: string) {
modelValue = language
}
</script>
<template>
<div>
<input
v-model="languageKeyword"
:placeholder="t('language.search')"
p2 mb2 border-rounded w-full bg-transparent
outline-none border="~ base"
>
<div max-h-40vh overflow-auto>
<CommonDropdownItem
v-for="{ code, nativeName, name } in languages"
:key="code"
:text="nativeName"
:description="name"
:checked="code === modelValue"
@click="chooseLanguage(code)"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { statusVisibilities } from '~/composables/masto/icons'
const { editing } = defineProps<{
editing?: boolean
}>()
let { modelValue } = $defineModel<{
modelValue: string
}>()
const currentVisibility = $computed(() =>
statusVisibilities.find(v => v.value === modelValue) || statusVisibilities[0],
)
const chooseVisibility = (visibility: string) => {
modelValue = visibility
}
</script>
<template>
<CommonTooltip placement="top" :content="editing ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
<CommonDropdown placement="bottom">
<slot :visibility="currentVisibility" />
<template #popper>
<CommonDropdownItem
v-for="visibility in statusVisibilities"
:key="visibility.value"
:icon="visibility.icon"
:text="$t(`visibility.${visibility.value}`)"
:description="$t(`visibility.${visibility.value}_desc`)"
:checked="visibility.value === modelValue"
@click="chooseVisibility(visibility.value)"
/>
</template>
</CommonDropdown>
</CommonTooltip>
</template>

View file

@ -3,8 +3,6 @@ import type { Attachment, CreateStatusParams, Status, StatusVisibility } from 'm
import { fileOpen } from 'browser-fs-access'
import { useDropZone } from '@vueuse/core'
import { EditorContent } from '@tiptap/vue-3'
import ISO6391 from 'iso-639-1'
import Fuse from 'fuse.js'
import type { Draft } from '~/types'
type FileUploadError = [filename: string, message: string]
@ -16,7 +14,7 @@ const {
placeholder,
dialogLabelledBy,
} = defineProps<{
draftKey: string
draftKey?: string
initial?: () => Draft
placeholder?: string
inReplyToId?: string
@ -40,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,
@ -55,10 +56,6 @@ const { editor } = useTiptap({
onPaste: handlePaste,
})
const currentVisibility = $computed(() => {
return STATUS_VISIBILITIES.find(v => v.value === draft.params.visibility) || STATUS_VISIBILITIES[0]
})
let isUploading = $ref<boolean>(false)
let isExceedingAttachmentLimit = $ref<boolean>(false)
let failed = $ref<FileUploadError[]>([])
@ -133,19 +130,12 @@ function removeAttachment(index: number) {
draft.attachments.splice(index, 1)
}
function chooseVisibility(visibility: StatusVisibility) {
draft.params.visibility = visibility
}
function chooseLanguage(language: string | null) {
draft.params.language = language
}
async function publish() {
const payload = {
...draft.params,
status: htmlToText(draft.params.status || ''),
mediaIds: draft.attachments.map(a => a.id),
...(masto.version.includes('+glitch') ? { 'content-type': 'text/markdown' } : {}),
} as CreateStatusParams
if (process.dev) {
@ -186,29 +176,6 @@ async function onDrop(files: File[] | null) {
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const languageKeyword = $ref('')
const languageList: {
code: string | null
nativeName: string
name?: string
}[] = [{
code: null,
nativeName: t('language.none'),
}, ...ISO6391.getAllCodes().map(code => ({
code,
nativeName: ISO6391.getNativeName(code),
name: ISO6391.getName(code),
}))]
const fuse = new Fuse(languageList, {
keys: ['code', 'nativeName', 'name'],
shouldSort: true,
})
const languages = $computed(() =>
languageKeyword.trim()
? fuse.search(languageKeyword).map(r => r.item)
: languageList,
)
defineExpose({
focusEditor: () => {
editor.value?.commands?.focus?.()
@ -230,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 layoutsso don't remove it unless you know what you're doing -->
<div
@ -302,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)"
/>
@ -318,16 +285,20 @@ defineExpose({
<PublishEmojiPicker
@select="insertEmoji"
@select-custom="insertCustomEmoji"
/>
>
<button btn-action-icon :title="$t('tooltip.emoji')">
<div i-ri:emotion-line />
</button>
</PublishEmojiPicker>
<CommonTooltip placement="bottom" :content="$t('tooltip.add_media')">
<CommonTooltip placement="top" :content="$t('tooltip.add_media')">
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
<div i-ri:image-add-line />
</button>
</CommonTooltip>
<template v-if="editor">
<CommonTooltip placement="bottom" :content="$t('tooltip.toggle_code_block')">
<CommonTooltip placement="top" :content="$t('tooltip.toggle_code_block')">
<button
btn-action-icon
:aria-label="$t('tooltip.toggle_code_block')"
@ -345,7 +316,7 @@ defineExpose({
{{ editor?.storage.characterCount.characters() }}<span text-secondary-light>/</span><span text-secondary-light>{{ characterLimit }}</span>
</div>
<CommonTooltip placement="bottom" :content="$t('tooltip.add_content_warning')">
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
<div v-else i-ri:alarm-warning-line />
@ -353,66 +324,29 @@ defineExpose({
</CommonTooltip>
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
<CommonDropdown placement="bottom">
<CommonDropdown placement="bottom" auto-boundary-max-size>
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-12 mr--1>
<div i-ri:translate-2 />
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
<div min-w-80 p3>
<input
v-model="languageKeyword"
:placeholder="t('language.search')"
p2 mb2 border-rounded w-full bg-transparent
outline-none border="~ base"
>
<div max-h-40vh overflow-auto>
<CommonDropdownItem
v-for="{ code, nativeName, name } in languages"
:key="code"
:checked="code === (draft.params.language || null)"
@click="chooseLanguage(code)"
>
{{ nativeName }}
<template #description>
<template v-if="name">
{{ name }}
</template>
</template>
</CommonDropdownItem>
</div>
</div>
<PublishLanguagePicker v-model="draft.params.language" min-w-80 p3 />
</template>
</CommonDropdown>
</CommonTooltip>
<CommonTooltip placement="bottom" :content="draft.editingStatus ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
<CommonDropdown>
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
<template #default="{ visibility }">
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
<div :class="currentVisibility.icon" />
<div :class="visibility.icon" />
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
</button>
<template #popper>
<CommonDropdownItem
v-for="visibility in STATUS_VISIBILITIES"
:key="visibility.value"
:icon="visibility.icon"
:checked="visibility.value === draft.params.visibility"
@click="chooseVisibility(visibility.value)"
>
{{ $t(`visibility.${visibility.value}`) }}
<template #description>
{{ $t(`visibility.${visibility.value}_desc`) }}
</template>
</CommonDropdownItem>
</template>
</CommonDropdown>
</CommonTooltip>
</template>
</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"
>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
const route = useRoute()
const router = useRouter()
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>
&middot; {{ 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>

View file

@ -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>

View file

@ -0,0 +1,24 @@
<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>
<ContentRich
line-clamp-1 ws-pre-wrap break-all text-base
:content="getDisplayName(account, { rich: true })"
:emojis="account.emojis"
/>
<AccountBotIndicator v-if="account.bot" />
</div>
<AccountHandle text-sm :account="account" text-secondary-light />
</div>
</button>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { History, 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>

View file

@ -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>

View file

@ -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 />

View file

@ -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

View file

@ -9,7 +9,7 @@ const fontSize = useFontSizeRef()
<template>
<select v-model="fontSize">
<option v-for="size in sizes" :key="size" :value="size" :selected="fontSize === size">
{{ `${size}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
{{ `${$t(`settings.interface.size_label.${size}`)}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
</option>
</select>
</template>

View file

@ -6,6 +6,9 @@ const props = defineProps<{
icon?: string
to?: string | Record<string, string>
command?: boolean
disabled?: boolean
external?: true
large?: true
}>()
const router = useRouter()
@ -32,9 +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
@ -49,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>
@ -70,7 +80,7 @@ useCommand({
{{ content }}
</slot>
</p>
<div v-if="to" i-ri:arrow-right-s-line text-xl text-secondary-light class="rtl-flip" />
<div v-if="to" :class="!external ? 'i-ri:arrow-right-s-line' : 'i-ri:external-link-line'" text-xl text-secondary-light class="rtl-flip" />
</div>
</NuxtLink>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { UpdateCredentialsParams } from 'masto'
import { accountFieldIcons, getAccountFieldIcon } from '~/composables/masto/icons'
const { form } = defineModel<{
form: {
@ -25,7 +26,7 @@ const chooseIcon = (i: number, text: string) => {
<div v-for="i in 4" :key="i" flex="~ gap3" items-center>
<CommonDropdown ref="dropdown" placement="left">
<CommonTooltip content="Pick a icon">
<button btn-action-icon>
<button type="button" btn-action-icon>
<div :class="fieldIcons[i - 1] || 'i-ri:question-mark'" />
</button>
</CommonTooltip>
@ -37,9 +38,9 @@ const chooseIcon = (i: number, text: string) => {
:content="text"
>
<template v-if="text !== 'Joined'">
<div btn-action-icon @click="chooseIcon(i - 1, text)">
<button type="button" btn-action-icon @click="chooseIcon(i - 1, text)">
<div text-xl :class="icon" />
</div>
</button>
</template>
</CommonTooltip>
</div>
@ -47,17 +48,13 @@ const chooseIcon = (i: number, text: string) => {
</CommonDropdown>
<input
v-model="form.fieldsAttributes[i - 1].name"
type="text"
p2 border-rounded w-full bg-transparent
outline-none border="~ base"
placeholder="Label"
type="text" placeholder="Label"
input-base
>
<input
v-model="form.fieldsAttributes[i - 1].value"
type="text"
p2 border-rounded w-full bg-transparent
outline-none border="~ base"
placeholder="Content"
type="text" placeholder="Content"
input-base
>
</div>
</div>

View file

@ -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()"

View file

@ -91,7 +91,7 @@ const deleteAndRedraft = async () => {
await openPublishDialog('dialog', await getDraftFromStatus(status), true)
// Go to the new status, if the page is the old status
if (lastPublishDialogStatus.value && route.matched.some(m => m.path === '/:server?/@:account/:status'))
if (lastPublishDialogStatus.value && route.name === 'status')
router.push(getStatusRoute(lastPublishDialogStatus.value))
}
@ -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"

View file

@ -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 = {
@ -126,6 +129,7 @@ useIntersectionObserver(video, (entries) => {
</template>
<template v-else>
<button
type="button"
focus:outline-none
focus:ring="2 primary inset"
rounded-lg

View file

@ -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>
@ -12,14 +13,15 @@ const { translation } = useTranslation(status)
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }">
<ContentRich
v-if="status.content"
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">
<div my2 h-px border="b base" bg-base />
<ContentRich :content="translation.text" :emojis="status.emojis" />
<ContentRich class="line-compact" :content="translation.text" :emojis="status.emojis" />
</template>
</div>
</template>

View file

@ -90,26 +90,30 @@ const isDM = $computed(() => status.visibility === 'direct')
ref="el"
relative flex flex-col gap-1 pl-3 pr-4 pt-1
class="pb-1.5"
transition-100
: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>
<div i-ri:repeat-fill me-46px text-primary w-16px h-16px />
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
<AccountAvatar :account="rebloggedBy" />
<AccountHoverWrapper :account="rebloggedBy">
<NuxtLink :to="getAccountRoute(rebloggedBy)">
<AccountAvatar :account="rebloggedBy" />
</NuxtLink>
</AccountHoverWrapper>
</div>
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="false" text-sm />
</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>
@ -122,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>
@ -134,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">
@ -151,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>

View file

@ -23,8 +23,8 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
<div
space-y-3
:class="{
'pt2 pb0.5 px3.5 bg-fade border-1 border-primary-light rounded-5 mx--1': isDM,
'ms--3.5 mt--1': isDM && context !== 'details',
'pt2 pb0.5 px3.5 bg-fade border-1 border-primary-light rounded-5 me--1': isDM,
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
}"
>
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Status } from 'masto'
import { statusVisibilities } from '~/composables/masto/icons'
const props = withDefaults(defineProps<{
status: Status
@ -17,7 +18,7 @@ const status = $computed(() => {
const createdAt = useFormattedDateTime(status.createdAt)
const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === status.visibility)!)
const visibility = $computed(() => statusVisibilities.find(v => v.value === status.visibility)!)
const { t } = useI18n()
@ -29,7 +30,7 @@ const isDM = $computed(() => status.visibility === 'direct')
</script>
<template>
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 px-4 relative :lang="status.language ?? undefined">
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 ps-3 pe-4 relative :lang="status.language ?? undefined">
<StatusActionsMore :status="status" absolute inset-ie-2 top-2 />
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
<AccountHoverWrapper :account="status.account">
@ -54,7 +55,12 @@ const isDM = $computed(() => status.visibility === 'direct')
<div v-if="status.application?.name">
&middot;
</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>

View file

@ -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>

View file

@ -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: regex test the card.title value
const isMastodonLink = true

View file

@ -20,6 +20,8 @@ interface Meta {
}
}
const specialRoutes = ['orgs', 'sponsors', 'stars']
const meta = $computed(() => {
const { url } = props.card
const path = url.split('https://github.com/')[1]
@ -27,13 +29,15 @@ const meta = $computed(() => {
// Supported paths
// /user
// /user/repo
// /user/repo/issues/number.*
// /user/repo/pull/number.*
// /orgs/user.*
// /user/repo/issues/number
// /user/repo/pull/number
// /orgs/user
// /sponsors/user
// /stars/user
const firstName = path.match(/([\w-]+)(\/|$)/)?.[1]
const secondName = path.match(/[\w-]+\/([\w-]+)/)?.[1]
const firstIsUser = firstName !== 'orgs' && firstName !== 'sponsors'
const firstIsUser = firstName && !specialRoutes.includes(firstName)
const user = firstIsUser ? firstName : secondName
const repo = firstIsUser ? secondName : undefined

View file

@ -26,7 +26,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>

View file

@ -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>

View file

@ -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()))
@ -8,6 +7,6 @@ onBeforeUnmount(() => stream?.then(s => s.disconnect()))
<template>
<div>
<PublishWidget draft-key="home" border="b base" />
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="timelineWithReorderedReplies" context="home" />
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
</div>
</template>

View file

@ -12,11 +12,11 @@ const { paginator, stream } = defineProps<{
}>()
const { formatNumber } = useHumanReadableNumber()
const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirtualScroll))
const virtualScroller = $(useFeatureFlag('experimentalVirtualScroll'))
</script>
<template>
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller">
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller" :is-account-timeline="context === 'account'">
<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) } }) }}

View 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>

View file

@ -1,8 +1,8 @@
<template>
<VDropdown :distance="0" placement="top-start">
<button btn-action-icon :aria-label="$t('action.switch_account')">
<div :class="{ 'hidden lg:block': currentUser }" i-ri:more-2-line />
<AccountAvatar v-if="currentUser" lg:hidden :account="currentUser.account" w-9 h-9 />
<div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line />
<AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square />
</button>
<template #popper="{ hide }">
<UserSwitcher @click="hide" />

View file

@ -25,7 +25,7 @@ const switchUser = (user: UserLogin) => {
hover="filter-none op100"
@click="switchUser(user)"
>
<AccountAvatar w-13 h-13 :account="user.account" />
<AccountAvatar w-13 h-13 :account="user.account" square />
</button>
</template>
</div>

View file

@ -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>

View file

@ -33,18 +33,12 @@ const switchUser = (user: UserLogin) => {
aria-label="Switch user"
@click="switchUser(user)"
>
<AccountInfo :account="user.account" :hover-card="false" />
<AccountInfo :account="user.account" :hover-card="false" square />
<div flex-auto />
<div v-if="user.token === currentUser?.token" i-ri:check-line text-primary mya text-2xl />
</button>
</template>
<div border="t base" pt2>
<NuxtLink to="/settings">
<CommonDropdownItem
:text="$t('nav.settings')"
icon="i-ri:settings-4-line"
/>
</NuxtLink>
<CommonDropdownItem
:text="$t('user.add_existing')"
icon="i-ri:user-add-line"

View file

@ -205,7 +205,7 @@ export const useCommandRegistry = defineStore('command', () => {
}
})
export const useCommand = (cmd: CommandProvider) => {
export function useCommand(cmd: CommandProvider) {
const registry = useCommandRegistry()
const register = () => registry.register(cmd)
@ -217,7 +217,7 @@ export const useCommand = (cmd: CommandProvider) => {
tryOnScopeDispose(cleanup)
}
export const useCommands = (cmds: () => CommandProvider[]) => {
export function useCommands(cmds: () => CommandProvider[]) {
const registry = useCommandRegistry()
const commands = computed(cmds)
@ -245,25 +245,11 @@ export const provideGlobalCommands = () => {
const masto = useMasto()
const colorMode = useColorMode()
useCommand({
scope: 'Actions',
visible: () => currentUser.value,
name: () => t('action.compose'),
icon: 'i-ri:quill-pen-line',
description: () => t('command.compose_desc'),
onActivate() {
openPublishDialog()
},
})
useCommand({
scope: 'Navigation',
name: () => t('nav.settings'),
icon: 'i-ri:settings-4-line',
icon: 'i-ri:settings-3-line',
onActivate() {
router.push('/settings')
@ -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
},
})

View file

@ -1,12 +1,15 @@
// @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'
const decoder = process.client ? document.createElement('textarea') : null as any as HTMLTextAreaElement
const decoder = process.client ? document.createElement('textarea') : null
export function decodeHtml(text: string) {
if (!decoder)
// not available when SSR
return text
decoder.innerHTML = text
return decoder.value
}
@ -16,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),
])
}
/**
@ -130,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)
}
}

View file

@ -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) {

View file

@ -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)

View file

@ -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 })

View file

@ -1,54 +0,0 @@
// @unocss-include
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
Alipay: 'i-ri:alipay-fill',
Bilibili: 'i-ri:bilibili-fill',
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',
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',
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',
Portfolio: 'i-ri:link',
QQ: 'i-ri:qq-fill',
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',
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',
YouTube: 'i-ri:youtube-line',
Zhihu: 'i-ri:zhihu-fill',
}).sort(([a], [b]) => a.localeCompare(b)))
const accountFieldIconsLowercase = Object.fromEntries(
Object.entries(accountFieldIcons).map(([k, v]) =>
[k.toLowerCase(), v],
),
)
export const getAccountFieldIcon = (value: string) => {
const name = value.trim().toLowerCase()
return accountFieldIconsLowercase[name] || undefined
}

View file

@ -1,5 +1,9 @@
import { InjectionKeyFontSize } from '~/constants/symbols'
import { InjectionKeyDropdownContext, InjectionKeyFontSize } from '~/constants/symbols'
export function useFontSizeRef() {
return inject(InjectionKeyFontSize)!
}
export function useDropdownContext() {
return inject(InjectionKeyDropdownContext, undefined)
}

View file

@ -7,25 +7,11 @@ export const useMasto = () => useNuxtApp().$masto as ElkMasto
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
// @unocss-include
export const STATUS_VISIBILITIES = [
{
value: 'public',
icon: 'i-ri:global-line',
},
{
value: 'unlisted',
icon: 'i-ri:lock-unlock-line',
},
{
value: 'private',
icon: 'i-ri:lock-line',
},
{
value: 'direct',
icon: 'i-ri:at-line',
},
] as const
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 || ''
@ -172,20 +158,3 @@ async function fetchRelationships() {
for (let i = 0; i < requested.length; i++)
requested[i][1].value = relationships[i]
}
const maxDistance = 10
export function timelineWithReorderedReplies(items: Status[]) {
const newItems = [...items]
// TODO: Basic reordering, we should get something more efficient and robust
for (let i = items.length - 1; i > 0; i--) {
for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
const inReplyToId = newItems[i - k].inReplyToId ?? newItems[i - k].reblog?.inReplyToId
if (inReplyToId && (inReplyToId === newItems[i].reblog?.id || inReplyToId === newItems[i].id)) {
const item = newItems.splice(i, 1)[0]
newItems.splice(i - k, 0, item)
k = 1
}
}
}
return newItems
}

View file

@ -0,0 +1,74 @@
// @unocss-include
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
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-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-line',
Location: 'i-ri:map-pin-2-line',
Mastodon: 'i-ri:mastodon-line',
Medium: 'i-ri:medium-line',
Patreon: 'i-ri:patreon-line',
PayPal: 'i-ri:paypal-line',
PlayStation: 'i-ri:playstation-line',
Portfolio: 'i-ri:link',
Pronouns: 'i-ri:contacts-line',
QQ: 'i-ri:qq-line',
Site: 'i-ri:link',
Sponsors: 'i-ri:heart-3-line',
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-line',
Weibo: 'i-ri:weibo-line',
Xbox: 'i-ri:xbox-line',
YouTube: 'i-ri:youtube-line',
Zhihu: 'i-ri:zhihu-line',
}).sort(([a], [b]) => a.localeCompare(b)))
const accountFieldIconsLowercase = Object.fromEntries(
Object.entries(accountFieldIcons).map(([k, v]) =>
[k.toLowerCase(), v],
),
)
export const getAccountFieldIcon = (value: string) => {
const name = value.trim().toLowerCase()
return accountFieldIconsLowercase[name] || undefined
}
export const statusVisibilities = [
{
value: 'public',
icon: 'i-ri:global-line',
},
{
value: 'unlisted',
icon: 'i-ri:lock-unlock-line',
},
{
value: 'private',
icon: 'i-ri:lock-line',
},
{
value: 'direct',
icon: 'i-ri:at-line',
},
] as const

View file

@ -86,7 +86,7 @@ export function usePaginator<T>(
}, 1000)
if (!isMastoInitialised.value) {
watchOnce(isMastoInitialised, () => {
onMastoInit(() => {
state.value = 'idle'
loadNext()
})

View 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
}

View 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)

View file

@ -1,48 +1,33 @@
import { pwaInfo } from 'virtual:pwa-info'
import type { Link } from '@unhead/schema'
import type { Directions } from 'vue-i18n-routing'
import { APP_NAME } from '~/constants'
import { buildInfo } from 'virtual:build-info'
import type { LocaleObject } from '#i18n'
export function setupPageHeader() {
const isDev = process.dev
const isPreview = useRuntimeConfig().public.env === 'staging'
const { locale, locales, t } = useI18n()
const i18n = 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 = (i18n.locales.value as LocaleObject[]).reduce((acc, l) => {
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
acc[l.code!] = l.dir ?? 'auto'
return acc
}, {} as Record<string, Directions>)
useHeadFixed({
htmlAttrs: {
lang: () => i18n.locale.value,
dir: () => localeMap[i18n.locale.value] ?? 'auto',
lang: () => locale.value,
dir: () => localeMap[locale.value] ?? 'auto',
},
titleTemplate: title => `${title ? `${title} | ` : ''}${APP_NAME}${isDev ? ' (dev)' : isPreview ? ' (preview)' : ''}`,
link,
titleTemplate: (title) => {
let titleTemplate = title ? `${title} | ` : ''
titleTemplate += t('app_name')
if (buildInfo.env !== 'release')
titleTemplate += ` (${buildInfo.env})`
return titleTemplate
},
link: process.client && useRuntimeConfig().public.pwaEnabled
? () => [{
key: 'webmanifest',
rel: 'manifest',
href: `/manifest-${locale.value}.webmanifest`,
}]
: [],
})
}

View file

@ -1,30 +1,40 @@
import type { Account, Status } from 'masto'
import type { Account, CreateStatusParams, Status } from 'masto'
import { STORAGE_KEY_DRAFTS } from '~/constants'
import type { Draft, DraftMap } from '~/types'
import type { Mutable } from '~/types/utils'
export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
export function getDefaultDraft(options: Partial<Draft['params'] & Omit<Draft, 'params'>> = {}): Draft {
export const builtinDraftKeys = [
'dialog',
'home',
]
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
const {
status = '',
inReplyToId,
visibility = 'public',
attachments = [],
initialText = '',
sensitive = false,
spoilerText = '',
status,
inReplyToId,
visibility,
sensitive,
spoilerText,
language,
} = options
return {
params: {
status,
inReplyToId,
visibility,
sensitive,
spoilerText,
},
attachments,
initialText,
params: {
status: status || '',
inReplyToId,
visibility: visibility || 'public',
sensitive: sensitive ?? false,
spoilerText: spoilerText || '',
language: language || 'en',
},
lastUpdated: Date.now(),
}
}
@ -36,6 +46,7 @@ export async function getDraftFromStatus(status: Status): Promise<Draft> {
attachments: status.mediaAttachments,
sensitive: status.sensitive,
spoilerText: status.spoilerText,
language: status.language,
})
}
@ -72,25 +83,27 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
}
export function useDraft(
draftKey: string,
draftKey?: string,
initial: () => Draft = () => getDefaultDraft({}),
) {
const draft = computed({
get() {
if (!currentUserDrafts.value[draftKey])
currentUserDrafts.value[draftKey] = initial()
return currentUserDrafts.value[draftKey]
},
set(val) {
currentUserDrafts.value[draftKey] = val
},
})
const draft = draftKey
? computed({
get() {
if (!currentUserDrafts.value[draftKey])
currentUserDrafts.value[draftKey] = initial()
return currentUserDrafts.value[draftKey]
},
set(val) {
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]
}
@ -111,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]
}
}

50
composables/timeline.ts Normal file
View file

@ -0,0 +1,50 @@
import type { Status } from 'masto'
const maxDistance = 10
const maxSteps = 1000
// Checks if (b) is a reply to (a)
function areStatusesConsecutive(a: Status, b: Status) {
const inReplyToId = b.inReplyToId ?? b.reblog?.inReplyToId
return !!inReplyToId && (inReplyToId === a.reblog?.id || inReplyToId === a.id)
}
export function reorderedTimeline(items: Status[]) {
let steps = 0
const newItems = [...items]
for (let i = items.length - 1; i > 0; i--) {
for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
// Prevent infinite loops
steps++
if (steps > maxSteps)
return newItems
// Check if the [i-k] item is a reply to the [i] item
// This means that they are in the wrong order
if (areStatusesConsecutive(newItems[i], newItems[i - k])) {
const item = newItems.splice(i, 1)[0]
newItems.splice(i - k, 0, item) // insert older item before the newer one
k = 0
}
else if (k > 1) {
// Check if the [i] item is a reply to the [i-k] item
// This means that they are in the correct order but there are posts between them
if (areStatusesConsecutive(newItems[i - k], newItems[i])) {
// If the next statuses are already ordered, move them all
let j = i
for (; j < items.length - 1; j++) {
if (!areStatusesConsecutive(newItems[j], newItems[j + 1]))
break
}
const orderedCount = j - i + 1
const itemsToMove = newItems.splice(i, orderedCount)
// insert older item after the newer one
newItems.splice(i - k + 1, 0, ...itemsToMove)
k = 0
}
}
}
}
return newItems
}

View file

@ -12,13 +12,13 @@ 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'
export interface UseTiptapOptions {
content: Ref<string | undefined>
content: Ref<string>
placeholder: Ref<string | undefined>
onSubmit: () => void
onFocus: () => void
@ -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,

View file

@ -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)
},
})

View file

@ -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()
},
}
}

View file

@ -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)

View file

@ -60,6 +60,37 @@ export const currentInstance = computed<null | Instance>(() => currentUser.value
export const publicServer = ref(DEFAULT_SERVER)
export const currentServer = computed<string>(() => currentUser.value?.server || publicServer.value)
// 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 currentUserHandle = computed(() => currentUser.value?.account.id
? `${currentUser.value.account.acct}@${currentInstance.value?.uri || currentServer.value}`
: '[anonymous]',

47
config/env.ts Normal file
View file

@ -0,0 +1,47 @@
import Git from 'simple-git'
import { isDevelopment } from 'std-env'
export { version } from '../package.json'
/**
* Environment variable `PULL_REQUEST` provided by Netlify.
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#git-metadata}
*
* Whether triggered by a GitHub PR
*/
export const isPR = process.env.PULL_REQUEST === 'true'
/**
* Environment variable `BRANCH` provided by Netlify.
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#git-metadata}
*
* Git branch
*/
export const gitBranch = process.env.BRANCH
/**
* Environment variable `CONTEXT` provided by Netlify.
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#build-metadata}
*
* Whether triggered by PR, `deploy-preview` or `dev`.
*/
export const isPreview = isPR || process.env.CONTEXT === 'deploy-preview' || process.env.CONTEXT === 'dev'
const git = Git()
export const getGitInfo = async () => {
const branch = gitBranch || await git.revparse(['--abbrev-ref', 'HEAD'])
const commit = await git.revparse(['HEAD'])
return { branch, commit }
}
export const getEnv = async () => {
const { commit, branch } = await getGitInfo()
const env = isDevelopment
? 'dev'
: isPreview
? 'preview'
: branch === 'main'
? 'canary'
: 'release'
return { commit, branch, env } as const
}

View file

@ -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',
@ -76,6 +81,9 @@ const datetimeFormats = Object.values(locales).reduce((acc, data) => {
}
else {
acc[data.code] = {
shortDate: {
dateStyle: 'short',
},
short: {
dateStyle: 'short',
timeStyle: 'short',

View file

@ -1,8 +1,6 @@
import { isCI, isDevelopment } from 'std-env'
import type { VitePWANuxtOptions } from '../modules/pwa/types'
const isPreview = process.env.PULL_REQUEST === 'true'
export const pwa: VitePWANuxtOptions = {
mode: isCI ? 'production' : 'development',
// disable PWA only when in preview mode
@ -13,39 +11,10 @@ export const pwa: VitePWANuxtOptions = {
strategies: 'injectManifest',
injectRegister: false,
includeManifestIcons: false,
manifest: {
scope: '/',
id: '/',
name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
short_name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
description: `A nimble Mastodon Web Client${isCI ? isPreview ? ' (preview)' : '' : ' (development)'}`,
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
/*
{
src: 'logo.svg',
sizes: '250x250',
type: 'image/png',
purpose: 'any maskable',
},
*/
],
},
manifest: false,
injectManifest: {
// fonts/seguiemj.ttf is 2.77 MB, and won't be precached
maximumFileSizeToCacheInBytes: 3000000,
globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'],
globIgnores: ['emojis/twemoji/*.svg'],
globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm,webmanifest}'],
globIgnores: ['emojis/**'],
},
devOptions: {
enabled: process.env.VITE_DEV_PWA === 'true',

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