feat: poll creation (#2111)

This commit is contained in:
Tuur Martens 2023-05-20 21:23:41 +02:00 committed by GitHub
parent d9add9f670
commit 1fda33848e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 237 additions and 88 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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