feat: allow running elk with a single server (#1606)

This commit is contained in:
Joaquín Sánchez 2023-02-05 13:10:19 +01:00 committed by GitHub
parent 61428cd9cd
commit 53d0812efd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 232 additions and 79 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -263,7 +263,7 @@ export async function signOut() {
if (!currentUserHandle.value) if (!currentUserHandle.value)
await useRouter().push('/') await useRouter().push('/')
loginTo(masto, currentUser.value) loginTo(masto, currentUser.value || { server: publicServer.value })
} }
export function checkLogin() { export function checkLogin() {

View file

@ -69,6 +69,7 @@
"save": "Save", "save": "Save",
"save_changes": "Save changes", "save_changes": "Save changes",
"sign_in": "Sign in", "sign_in": "Sign in",
"sign_in_to": "Sign in to {0}",
"switch_account": "Switch account", "switch_account": "Switch account",
"vote": "Vote" "vote": "Vote"
}, },

View file

@ -68,6 +68,7 @@
"save": "Guardar", "save": "Guardar",
"save_changes": "Guardar cambios", "save_changes": "Guardar cambios",
"sign_in": "Iniciar sesión", "sign_in": "Iniciar sesión",
"sign_in_to": "Iniciar sesión en {0}",
"switch_account": "Cambiar cuenta", "switch_account": "Cambiar cuenta",
"vote": "Votar" "vote": "Votar"
}, },

View file

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

View file

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

View file

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

View file

@ -96,6 +96,7 @@ export default defineNuxtConfig({
}, },
}, },
appConfig: { appConfig: {
singleInstanceServer: process.env.SINGLE_INSTANCE_SERVER === 'true',
storage: { storage: {
driver: process.env.NUXT_STORAGE_DRIVER ?? (isCI ? 'cloudflare' : 'fs'), driver: process.env.NUXT_STORAGE_DRIVER ?? (isCI ? 'cloudflare' : 'fs'),
}, },

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ definePageMeta({
key: route => `${route.params.server}:${route.params.account}`, key: route => `${route.params.server ?? currentServer.value}:${route.params.account}`,
}) })
const params = useRoute().params const params = useRoute().params

View file

@ -5,11 +5,11 @@ definePageMeta({
middleware: 'auth', middleware: 'auth',
}) })
const list = $computed(() => useRoute().params.list as string)
const server = $computed(() => useRoute().params.server as string)
const { t } = useI18n()
const route = useRoute() const route = useRoute()
const { t } = useI18n()
const list = $computed(() => route.params.list as string)
const server = $computed(() => (route.params.server ?? currentServer.value) as string)
const tabs = $computed<CommonRouteTabOption[]>(() => [ const tabs = $computed<CommonRouteTabOption[]>(() => [
{ {

View file

@ -1,5 +1,6 @@
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const { params, query } = useRoute() const { params, query } = useRoute()
publicServer.value = params.server as string || useRuntimeConfig().public.defaultServer publicServer.value = params.server as string || useRuntimeConfig().public.defaultServer
const masto = createMasto() const masto = createMasto()

View file

@ -4,7 +4,7 @@ const BOT_RE = /bot\b|index|spider|facebookexternalhit|crawl|wget|slurp|mediapar
export default defineNuxtPlugin(async (nuxtApp) => { export default defineNuxtPlugin(async (nuxtApp) => {
const route = useRoute() const route = useRoute()
if (!route.params.server) if (!('server' in route.params))
return return
const userAgent = useRequestHeaders()['user-agent'] const userAgent = useRequestHeaders()['user-agent']

View file

@ -63,6 +63,12 @@ export interface ConfirmDialogLabel {
} }
export type ConfirmDialogChoice = 'confirm' | 'cancel' export type ConfirmDialogChoice = 'confirm' | 'cancel'
export interface ErrorDialogData {
title: string
messages: string[]
close: string
}
export interface BuildInfo { export interface BuildInfo {
version: string version: string
commit: string commit: string