forked from Mirrors/elk
feat: poll creation (#2111)
This commit is contained in:
parent
d9add9f670
commit
1fda33848e
6 changed files with 237 additions and 88 deletions
|
@ -1,10 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label: string
|
label?: string
|
||||||
hover?: boolean
|
hover?: boolean
|
||||||
|
iconChecked?: string
|
||||||
|
iconUnchecked?: string
|
||||||
}>()
|
}>()
|
||||||
const { modelValue } = defineModels<{
|
const { modelValue } = defineModels<{
|
||||||
modelValue?: boolean
|
modelValue?: boolean | null
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -12,11 +14,12 @@ const { modelValue } = defineModels<{
|
||||||
<label
|
<label
|
||||||
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
||||||
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
||||||
|
v-bind="$attrs"
|
||||||
@click.prevent="modelValue = !modelValue"
|
@click.prevent="modelValue = !modelValue"
|
||||||
>
|
>
|
||||||
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
<span v-if="label" flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
:class="modelValue ? (iconChecked ?? 'i-ri:checkbox-line') : (iconUnchecked ?? 'i-ri:checkbox-blank-line')"
|
||||||
text-lg
|
text-lg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -63,6 +63,41 @@ const { editor } = useTiptap({
|
||||||
onPaste: handlePaste,
|
onPaste: handlePaste,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function editPollOptionDraft(event: Event, index: number) {
|
||||||
|
draft.params.poll!.options[index] = (event.target as HTMLInputElement).value
|
||||||
|
const indexLastNonEmpty = draft.params.poll!.options.findLastIndex(option => option.trim().length > 0)
|
||||||
|
draft.params.poll!.options = [...draft.params.poll!.options.slice(0, indexLastNonEmpty + 1), '']
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePollOption(index: number) {
|
||||||
|
draft.params.poll!.options.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInOptions = [
|
||||||
|
{
|
||||||
|
seconds: 1 * 60 * 60,
|
||||||
|
label: t('time_ago_options.hour_future', 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 2 * 60 * 60,
|
||||||
|
label: t('time_ago_options.hour_future', 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 1 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 2 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 7 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 7),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const expiresInDefaultOptionIndex = 2
|
||||||
|
|
||||||
const characterCount = $computed(() => {
|
const characterCount = $computed(() => {
|
||||||
const text = htmlToText(editor.value?.getHTML() || '')
|
const text = htmlToText(editor.value?.getHTML() || '')
|
||||||
|
|
||||||
|
@ -277,6 +312,34 @@ onDeactivated(() => {
|
||||||
</div>
|
</div>
|
||||||
<div flex gap-4>
|
<div flex gap-4>
|
||||||
<div w-12 h-full sm:block hidden />
|
<div w-12 h-full sm:block hidden />
|
||||||
|
<div flex="~ col 1" max-w-full>
|
||||||
|
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in draft.params.poll.options"
|
||||||
|
:key="index"
|
||||||
|
flex="~ row"
|
||||||
|
gap-3
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="option"
|
||||||
|
bg-base
|
||||||
|
border="~ base" flex-1 h10 pe-4 rounded-2 w-full flex="~ row"
|
||||||
|
items-center relative focus-within:box-shadow-outline gap-3
|
||||||
|
px-4 py-2
|
||||||
|
:placeholder="$t('polls.option_placeholder')"
|
||||||
|
@input="editPollOptionDraft($event, index)"
|
||||||
|
>
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.remove_option')">
|
||||||
|
<button
|
||||||
|
btn-action-icon class="hover:bg-red/75"
|
||||||
|
:disabled="index === draft.params.poll!.options.length - 1"
|
||||||
|
@click.prevent="deletePollOption(index)"
|
||||||
|
>
|
||||||
|
<div i-ri:delete-bin-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<div
|
<div
|
||||||
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
||||||
border="t base"
|
border="t base"
|
||||||
|
@ -290,12 +353,58 @@ onDeactivated(() => {
|
||||||
</button>
|
</button>
|
||||||
</PublishEmojiPicker>
|
</PublishEmojiPicker>
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.add_media')">
|
<CommonTooltip v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')">
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||||
<div i-ri:image-add-line />
|
<div i-ri:image-add-line />
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
|
||||||
|
<template v-if="draft.attachments.length === 0">
|
||||||
|
<CommonTooltip v-if="!draft.params.poll" placement="top" :content="$t('polls.create')">
|
||||||
|
<button btn-action-icon :aria-label="$t('polls.create')" @click="draft.params.poll = { options: [''], expiresIn: expiresInOptions[expiresInDefaultOptionIndex].seconds }">
|
||||||
|
<div i-ri:chat-poll-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<div v-else rounded-full b-1 border-dark flex="~ row" gap-1>
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.cancel')">
|
||||||
|
<button btn-action-icon b-r border-dark :aria-label="$t('polls.cancel')" @click="draft.params.poll = undefined">
|
||||||
|
<div i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<CommonDropdown placement="top">
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.settings')">
|
||||||
|
<button :aria-label="$t('polls.settings')" btn-action-icon w-12>
|
||||||
|
<div i-ri:list-settings-line />
|
||||||
|
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<template #popper>
|
||||||
|
<div flex="~ col" gap-1 p-2>
|
||||||
|
<CommonCheckbox v-model="draft.params.poll.multiple" :label="draft.params.poll.multiple ? $t('polls.disallow_multiple') : $t('polls.allow_multiple')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:checkbox-multiple-blank-line" icon-unchecked="i-ri:checkbox-blank-circle-line" />
|
||||||
|
<CommonCheckbox v-model="draft.params.poll.hideTotals" :label="draft.params.poll.hideTotals ? $t('polls.show_votes') : $t('polls.hide_votes')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:eye-close-line" icon-unchecked="i-ri:eye-line" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CommonDropdown>
|
||||||
|
<CommonDropdown placement="bottom">
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.expiration')">
|
||||||
|
<button :aria-label="$t('polls.expiration')" btn-action-icon w-12>
|
||||||
|
<div i-ri:hourglass-line />
|
||||||
|
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<template #popper>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-for="expiresInOption in expiresInOptions"
|
||||||
|
:key="expiresInOption.seconds"
|
||||||
|
:text="expiresInOption.label"
|
||||||
|
:checked="draft.params.poll!.expiresIn === expiresInOption.seconds"
|
||||||
|
@click="draft.params.poll!.expiresIn = expiresInOption.seconds"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CommonDropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<PublishEditorTools v-if="editor" :editor="editor" />
|
<PublishEditorTools v-if="editor" :editor="editor" />
|
||||||
|
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
|
@ -366,6 +475,7 @@ onDeactivated(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -34,7 +34,15 @@ export function usePublish(options: {
|
||||||
|
|
||||||
const shouldExpanded = $computed(() => expanded || isExpanded || !isEmpty)
|
const shouldExpanded = $computed(() => expanded || isExpanded || !isEmpty)
|
||||||
const isPublishDisabled = $computed(() => {
|
const isPublishDisabled = $computed(() => {
|
||||||
return isEmpty || isUploading || isSending || (draft.attachments.length === 0 && !draft.params.status) || failedMessages.length > 0
|
return isEmpty
|
||||||
|
|| isUploading
|
||||||
|
|| isSending
|
||||||
|
|| (draft.attachments.length === 0 && !draft.params.status)
|
||||||
|
|| failedMessages.length > 0
|
||||||
|
|| (draft.attachments.length > 0 && draft.params.poll !== null && draft.params.poll !== undefined)
|
||||||
|
|| (draft.params.poll !== null && draft.params.poll !== undefined && draft.params.poll.options.length <= 1)
|
||||||
|
|| (draft.params.poll !== null && draft.params.poll !== undefined && ![-1, draft.params.poll.options.length - 1].includes(draft.params.poll.options.findIndex(option => option.trim().length === 0)))
|
||||||
|
|| (draft.params.poll !== null && draft.params.poll !== undefined && new Set(draft.params.poll.options).size !== draft.params.poll.options.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => draft, () => {
|
watch(() => draft, () => {
|
||||||
|
@ -56,6 +64,7 @@ export function usePublish(options: {
|
||||||
status: content,
|
status: content,
|
||||||
mediaIds: draft.attachments.map(a => a.id),
|
mediaIds: draft.attachments.map(a => a.id),
|
||||||
language: draft.params.language || preferredLanguage,
|
language: draft.params.language || preferredLanguage,
|
||||||
|
poll: draft.params.poll ? { ...draft.params.poll, options: draft.params.poll.options.slice(0, draft.params.poll.options.length - 1) } : undefined,
|
||||||
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
...(isGlitchEdition.value ? { 'content-type': 'text/markdown' } : {}),
|
||||||
} as mastodon.v1.CreateStatusParams
|
} as mastodon.v1.CreateStatusParams
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
|
||||||
spoilerText,
|
spoilerText,
|
||||||
language,
|
language,
|
||||||
mentions,
|
mentions,
|
||||||
|
poll,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -43,6 +44,7 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
|
||||||
initialText,
|
initialText,
|
||||||
params: {
|
params: {
|
||||||
status: status || '',
|
status: status || '',
|
||||||
|
poll,
|
||||||
inReplyToId,
|
inReplyToId,
|
||||||
visibility: getDefaultVisibility(visibility || 'public'),
|
visibility: getDefaultVisibility(visibility || 'public'),
|
||||||
sensitive: sensitive ?? false,
|
sensitive: sensitive ?? false,
|
||||||
|
@ -55,15 +57,28 @@ export function getDefaultDraft(options: Partial<Mutable<mastodon.v1.CreateStatu
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<Draft> {
|
export async function getDraftFromStatus(status: mastodon.v1.Status): Promise<Draft> {
|
||||||
return getDefaultDraft({
|
const info = {
|
||||||
status: await convertMastodonHTML(status.content),
|
status: await convertMastodonHTML(status.content),
|
||||||
mediaIds: status.mediaAttachments.map(att => att.id),
|
|
||||||
visibility: status.visibility,
|
visibility: status.visibility,
|
||||||
attachments: status.mediaAttachments,
|
attachments: status.mediaAttachments,
|
||||||
sensitive: status.sensitive,
|
sensitive: status.sensitive,
|
||||||
spoilerText: status.spoilerText,
|
spoilerText: status.spoilerText,
|
||||||
language: status.language,
|
language: status.language,
|
||||||
inReplyToId: status.inReplyToId,
|
inReplyToId: status.inReplyToId,
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultDraft((status.mediaAttachments !== undefined && status.mediaAttachments.length > 0)
|
||||||
|
? { ...info, mediaIds: status.mediaAttachments.map(att => att.id) }
|
||||||
|
: {
|
||||||
|
...info,
|
||||||
|
poll: status.poll
|
||||||
|
? {
|
||||||
|
expiresIn: Math.abs(new Date().getTime() - new Date(status.poll.expiresAt!).getTime()) / 1000,
|
||||||
|
options: [...status.poll.options.map(({ title }) => title), ''],
|
||||||
|
multiple: status.poll.multiple,
|
||||||
|
hideTotals: status.poll.options[0].votesCount === null,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -307,6 +307,18 @@
|
||||||
"replying": "Replying",
|
"replying": "Replying",
|
||||||
"the_thread": "the thread"
|
"the_thread": "the thread"
|
||||||
},
|
},
|
||||||
|
"polls": {
|
||||||
|
"allow_multiple": "Allow multiple choice",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create poll",
|
||||||
|
"disallow_multiple": "Disallow multiple choice",
|
||||||
|
"expiration": "Poll expiration",
|
||||||
|
"hide_votes": "Hide vote totals until the end",
|
||||||
|
"option_placeholder": "Poll choice",
|
||||||
|
"remove_option": "Remove choice",
|
||||||
|
"settings": "Poll options",
|
||||||
|
"show_votes": "Always show vote totals"
|
||||||
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
|
|
|
@ -47,7 +47,7 @@ export type TranslateFn = ReturnType<typeof useI18n>['t']
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
editingStatus?: mastodon.v1.Status
|
editingStatus?: mastodon.v1.Status
|
||||||
initialText?: string
|
initialText?: string
|
||||||
params: MarkNonNullable<Mutable<mastodon.v1.CreateStatusParams>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'>
|
params: MarkNonNullable<Mutable<Omit<mastodon.v1.CreateStatusParams, 'poll'>>, 'status' | 'language' | 'sensitive' | 'spoilerText' | 'visibility'> & { poll: Mutable<mastodon.v1.CreateStatusParams['poll']> }
|
||||||
attachments: mastodon.v1.MediaAttachment[]
|
attachments: mastodon.v1.MediaAttachment[]
|
||||||
lastUpdated: number
|
lastUpdated: number
|
||||||
mentions?: string[]
|
mentions?: string[]
|
||||||
|
|
Loading…
Reference in a new issue