refactor: tidy composables
This commit is contained in:
parent
bf8070c4b9
commit
e0741d58a9
14 changed files with 177 additions and 175 deletions
53
composables/masto/account.ts
Normal file
53
composables/masto/account.ts
Normal 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),
|
||||
)
|
||||
}
|
11
composables/masto/masto.ts
Normal file
11
composables/masto/masto.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { ElkMasto } from '~/types'
|
||||
|
||||
export const useMasto = () => useNuxtApp().$masto as ElkMasto
|
||||
|
||||
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
|
||||
|
||||
export const onMastoInit = (cb: () => unknown) => {
|
||||
watchOnce(isMastoInitialised, () => {
|
||||
cb()
|
||||
}, { immediate: isMastoInitialised.value })
|
||||
}
|
33
composables/masto/relationship.ts
Normal file
33
composables/masto/relationship.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import type { Account, Relationship } from 'masto'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
// Batch requests for relationships when used in the UI
|
||||
// We don't want to hold to old values, so every time a Relationship is needed it
|
||||
// is requested again from the server to show the latest state
|
||||
|
||||
const requestedRelationships = new Map<string, Ref<Relationship | undefined>>()
|
||||
let timeoutHandle: NodeJS.Timeout | undefined
|
||||
|
||||
export function useRelationship(account: Account): Ref<Relationship | undefined> {
|
||||
if (!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]
|
||||
}
|
65
composables/masto/routes.ts
Normal file
65
composables/masto/routes.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { withoutProtocol } from 'ufo'
|
||||
import type { Account, Status } from 'masto'
|
||||
|
||||
export function getAccountRoute(account: Account) {
|
||||
return useRouter().resolve({
|
||||
name: 'account-index',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
account: extractAccountHandle(account),
|
||||
},
|
||||
})
|
||||
}
|
||||
export function getAccountFollowingRoute(account: Account) {
|
||||
return useRouter().resolve({
|
||||
name: 'account-following',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
account: extractAccountHandle(account),
|
||||
},
|
||||
})
|
||||
}
|
||||
export function getAccountFollowersRoute(account: Account) {
|
||||
return useRouter().resolve({
|
||||
name: 'account-followers',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
account: extractAccountHandle(account),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getStatusRoute(status: Status) {
|
||||
return useRouter().resolve({
|
||||
name: 'status',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
account: extractAccountHandle(status.account),
|
||||
status: status.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getTagRoute(tag: string) {
|
||||
return useRouter().resolve({
|
||||
name: 'tag',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
tag,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getStatusPermalinkRoute(status: Status) {
|
||||
return status.url ? withoutProtocol(status.url) : null
|
||||
}
|
||||
|
||||
export function getStatusInReplyToRoute(status: Status) {
|
||||
return useRouter().resolve({
|
||||
name: 'status-by-id',
|
||||
params: {
|
||||
server: currentServer.value,
|
||||
status: status.inReplyToId,
|
||||
},
|
||||
})
|
||||
}
|
70
composables/masto/search.ts
Normal file
70
composables/masto/search.ts
Normal 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,
|
||||
}
|
||||
}
|
88
composables/masto/status.ts
Normal file
88
composables/masto/status.ts
Normal 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,
|
||||
}
|
||||
}
|
135
composables/masto/statusDrafts.ts
Normal file
135
composables/masto/statusDrafts.ts
Normal 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]
|
||||
}
|
||||
}
|
50
composables/masto/translate.ts
Normal file
50
composables/masto/translate.ts
Normal 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,
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue