refactor: tidy composables

This commit is contained in:
三咲智子 2023-01-07 02:40:15 +08:00
parent bf8070c4b9
commit e0741d58a9
No known key found for this signature in database
GPG key ID: 69992F2250DFD93E
14 changed files with 177 additions and 175 deletions

View file

@ -0,0 +1,53 @@
import type { Account } from 'masto'
export function getDisplayName(account?: Account, options?: { rich?: boolean }) {
const displayName = account?.displayName || account?.username || ''
if (options?.rich)
return displayName
return displayName.replace(/:([\w-]+?):/g, '')
}
export function getShortHandle({ acct }: Account) {
if (!acct)
return ''
return `@${acct.includes('@') ? acct.split('@')[0] : acct}`
}
export function getServerName(account: Account) {
if (account.acct?.includes('@'))
return account.acct.split('@')[1]
// We should only lack the server name if we're on the same server as the account
return currentInstance.value?.uri || ''
}
export function getFullHandle(account: Account) {
const handle = `@${account.acct}`
if (!currentUser.value || account.acct.includes('@'))
return handle
return `${handle}@${getServerName(account)}`
}
export function toShortHandle(fullHandle: string) {
if (!currentUser.value)
return fullHandle
const server = currentUser.value.server
if (fullHandle.endsWith(`@${server}`))
return fullHandle.slice(0, -server.length - 1)
return fullHandle
}
export function extractAccountHandle(account: Account) {
let handle = getFullHandle(account).slice(1)
const uri = currentInstance.value?.uri ?? currentServer.value
if (currentInstance.value && handle.endsWith(`@${uri}`))
handle = handle.slice(0, -uri.length - 1)
return handle
}
export function useAccountHandle(account: Account, fullServer = true) {
return computed(() => fullServer
? getFullHandle(account)
: getShortHandle(account),
)
}

View file

@ -0,0 +1,11 @@
import type { ElkMasto } from '~/types'
export const useMasto = () => useNuxtApp().$masto as ElkMasto
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
export const onMastoInit = (cb: () => unknown) => {
watchOnce(isMastoInitialised, () => {
cb()
}, { immediate: isMastoInitialised.value })
}

View file

@ -0,0 +1,33 @@
import type { Account, Relationship } from 'masto'
import type { Ref } from 'vue'
// Batch requests for relationships when used in the UI
// We don't want to hold to old values, so every time a Relationship is needed it
// is requested again from the server to show the latest state
const requestedRelationships = new Map<string, Ref<Relationship | undefined>>()
let timeoutHandle: NodeJS.Timeout | undefined
export function useRelationship(account: Account): Ref<Relationship | undefined> {
if (!currentUser.value)
return ref()
let relationship = requestedRelationships.get(account.id)
if (relationship)
return relationship
relationship = ref<Relationship | undefined>()
requestedRelationships.set(account.id, relationship)
if (timeoutHandle)
clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
timeoutHandle = undefined
fetchRelationships()
}, 100)
return relationship
}
async function fetchRelationships() {
const requested = Array.from(requestedRelationships.entries()).filter(([, r]) => !r.value)
const relationships = await useMasto().accounts.fetchRelationships(requested.map(([id]) => id))
for (let i = 0; i < requested.length; i++)
requested[i][1].value = relationships[i]
}

View file

@ -0,0 +1,65 @@
import { withoutProtocol } from 'ufo'
import type { Account, Status } from 'masto'
export function getAccountRoute(account: Account) {
return useRouter().resolve({
name: 'account-index',
params: {
server: currentServer.value,
account: extractAccountHandle(account),
},
})
}
export function getAccountFollowingRoute(account: Account) {
return useRouter().resolve({
name: 'account-following',
params: {
server: currentServer.value,
account: extractAccountHandle(account),
},
})
}
export function getAccountFollowersRoute(account: Account) {
return useRouter().resolve({
name: 'account-followers',
params: {
server: currentServer.value,
account: extractAccountHandle(account),
},
})
}
export function getStatusRoute(status: Status) {
return useRouter().resolve({
name: 'status',
params: {
server: currentServer.value,
account: extractAccountHandle(status.account),
status: status.id,
},
})
}
export function getTagRoute(tag: string) {
return useRouter().resolve({
name: 'tag',
params: {
server: currentServer.value,
tag,
},
})
}
export function getStatusPermalinkRoute(status: Status) {
return status.url ? withoutProtocol(status.url) : null
}
export function getStatusInReplyToRoute(status: Status) {
return useRouter().resolve({
name: 'status-by-id',
params: {
server: currentServer.value,
status: status.inReplyToId,
},
})
}

View file

@ -0,0 +1,70 @@
import type { MaybeRef } from '@vueuse/core'
import type { Account, Paginator, Results, SearchParams, Status } from 'masto'
export interface UseSearchOptions {
type?: MaybeRef<'accounts' | 'hashtags' | 'statuses'>
}
export function useSearch(query: MaybeRef<string>, options?: UseSearchOptions) {
const done = ref(false)
const masto = useMasto()
const loading = ref(false)
const statuses = ref<Status[]>([])
const accounts = ref<Account[]>([])
const hashtags = ref<any[]>([])
let paginator: Paginator<SearchParams, Results> | undefined
debouncedWatch(() => unref(query), async () => {
if (!unref(query) || !isMastoInitialised.value)
return
loading.value = true
/**
* Based on the source it seems like modifying the params when calling next would result in a new search,
* but that doesn't seem to be the case. So instead we just create a new paginator with the new params.
*/
paginator = masto.search({ q: unref(query), resolve: !!currentUser.value, type: unref(options?.type) })
const nextResults = await paginator.next()
done.value = nextResults.done || false
statuses.value = nextResults.value?.statuses || []
accounts.value = nextResults.value?.accounts || []
hashtags.value = nextResults.value?.hashtags || []
loading.value = false
}, { debounce: 500 })
const next = async () => {
if (!unref(query) || !isMastoInitialised.value || !paginator)
return
loading.value = true
const nextResults = await paginator.next()
loading.value = false
done.value = nextResults.done || false
statuses.value = [
...statuses.value,
...(nextResults.value.statuses || []),
]
accounts.value = [
...statuses.value,
...(nextResults.value.accounts || []),
]
hashtags.value = [
...statuses.value,
...(nextResults.value.statuses || []),
]
}
return {
accounts,
hashtags,
statuses,
loading: readonly(loading),
next,
}
}

View file

@ -0,0 +1,88 @@
import type { Status } from 'masto'
type Action = 'reblogged' | 'favourited' | 'bookmarked' | 'pinned' | 'muted'
type CountField = 'reblogsCount' | 'favouritesCount'
export interface StatusActionsProps {
status: Status
}
export function useStatusActions(props: StatusActionsProps) {
let status = $ref<Status>({ ...props.status })
const masto = useMasto()
watch(
() => props.status,
val => status = { ...val },
{ deep: true, immediate: true },
)
// Use different states to let the user press different actions right after the other
const isLoading = $ref({
reblogged: false,
favourited: false,
bookmarked: false,
pinned: false,
translation: false,
muted: false,
})
async function toggleStatusAction(action: Action, fetchNewStatus: () => Promise<Status>, countField?: CountField) {
// check login
if (!checkLogin())
return
isLoading[action] = true
fetchNewStatus().then((newStatus) => {
Object.assign(status, newStatus)
cacheStatus(newStatus, undefined, true)
}).finally(() => {
isLoading[action] = false
})
// Optimistic update
status[action] = !status[action]
cacheStatus(status, undefined, true)
if (countField)
status[countField] += status[action] ? 1 : -1
}
const toggleReblog = () => toggleStatusAction(
'reblogged',
() => masto.statuses[status.reblogged ? 'unreblog' : 'reblog'](status.id).then((res) => {
if (status.reblogged)
// returns the original status
return res.reblog!
return res
}),
'reblogsCount',
)
const toggleFavourite = () => toggleStatusAction(
'favourited',
() => masto.statuses[status.favourited ? 'unfavourite' : 'favourite'](status.id),
'favouritesCount',
)
const toggleBookmark = () => toggleStatusAction(
'bookmarked',
() => masto.statuses[status.bookmarked ? 'unbookmark' : 'bookmark'](status.id),
)
const togglePin = async () => toggleStatusAction(
'pinned',
() => masto.statuses[status.pinned ? 'unpin' : 'pin'](status.id),
)
const toggleMute = async () => toggleStatusAction(
'muted',
() => masto.statuses[status.muted ? 'unmute' : 'mute'](status.id),
)
return {
status: $$(status),
isLoading: $$(isLoading),
toggleMute,
toggleReblog,
toggleFavourite,
toggleBookmark,
togglePin,
}
}

View file

@ -0,0 +1,135 @@
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 const builtinDraftKeys = [
'dialog',
'home',
]
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
const {
attachments = [],
initialText = '',
status,
inReplyToId,
visibility,
sensitive,
spoilerText,
language,
} = options
return {
attachments,
initialText,
params: {
status: status || '',
inReplyToId,
visibility: visibility || 'public',
sensitive: sensitive ?? false,
spoilerText: spoilerText || '',
language: language || 'en',
},
lastUpdated: Date.now(),
}
}
export async function getDraftFromStatus(status: Status): Promise<Draft> {
return getDefaultDraft({
status: await convertMastodonHTML(status.content),
mediaIds: status.mediaAttachments.map(att => att.id),
visibility: status.visibility,
attachments: status.mediaAttachments,
sensitive: status.sensitive,
spoilerText: status.spoilerText,
language: status.language,
})
}
function mentionHTML(acct: string) {
return `<span data-type="mention" data-id="${acct}" contenteditable="false">@${acct}</span>`
}
export function getReplyDraft(status: Status) {
const accountsToMention: string[] = []
const userId = currentUser.value?.account.id
if (status.account.id !== userId)
accountsToMention.push(status.account.acct)
accountsToMention.push(...(status.mentions.filter(mention => mention.id !== userId).map(mention => mention.acct)))
return {
key: `reply-${status.id}`,
draft: () => {
return getDefaultDraft({
initialText: accountsToMention.map(acct => mentionHTML(acct)).join(' '),
inReplyToId: status!.id,
visibility: status.visibility,
})
},
}
}
export const isEmptyDraft = (draft: Draft | null | undefined) => {
if (!draft)
return true
const { params, attachments } = draft
const status = params.status || ''
return (status.length === 0 || status === '<p></p>')
&& attachments.length === 0
&& (params.spoilerText || '').length === 0
}
export function useDraft(
draftKey?: string,
initial: () => Draft = () => getDefaultDraft({}),
) {
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 && draftKey) {
await nextTick()
delete currentUserDrafts.value[draftKey]
}
})
return { draft, isEmpty }
}
export function mentionUser(account: Account) {
openPublishDialog('dialog', getDefaultDraft({
status: `@${account.acct} `,
}), true)
}
export function directMessageUser(account: Account) {
openPublishDialog('dialog', getDefaultDraft({
status: `@${account.acct} `,
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]
}
}

View file

@ -0,0 +1,50 @@
import type { Status, StatusEdit } from 'masto'
export interface TranslationResponse {
translatedText: string
detectedLanguage: {
confidence: number
language: string
}
}
export const languageCode = process.server ? 'en' : navigator.language.replace(/-.*$/, '')
export async function translateText(text: string, from?: string | null, to?: string) {
const config = useRuntimeConfig()
const { translatedText } = await $fetch<TranslationResponse>(config.public.translateApi, {
method: 'POST',
body: {
q: text,
source: from ?? 'auto',
target: to ?? languageCode,
format: 'html',
api_key: '',
},
})
return translatedText
}
const translations = new WeakMap<Status | StatusEdit, { visible: boolean; text: string }>()
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)
translation.visible = !translation.visible
}
return {
enabled: !!useRuntimeConfig().public.translateApi,
toggle,
translation,
}
}