✨ Chat attachments
This commit is contained in:
parent
a5efec89f2
commit
634fedf17c
@ -2,7 +2,6 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app,
|
#app,
|
||||||
.v-application {
|
.v-application {
|
||||||
overflow: auto !important;
|
|
||||||
font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
|
font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,22 +13,53 @@
|
|||||||
v-model="data.content"
|
v-model="data.content"
|
||||||
@keyup.ctrl.enter="sendMessage"
|
@keyup.ctrl.enter="sendMessage"
|
||||||
@keyup.meta.enter="sendMessage"
|
@keyup.meta.enter="sendMessage"
|
||||||
|
@paste="pasteMedia"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
type="button"
|
||||||
|
color="teal"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="dialogs.attachments = true"
|
||||||
|
>
|
||||||
|
<v-badge v-if="data.attachments.length > 0" :content="data.attachments.length">
|
||||||
|
<v-icon icon="mdi-paperclip" />
|
||||||
|
</v-badge>
|
||||||
|
|
||||||
|
<v-icon v-else icon="mdi-paperclip" />
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
<v-btn type="submit" icon="mdi-send" size="small" variant="text" :disabled="loading" />
|
<v-btn type="submit" icon="mdi-send" size="small" variant="text" :disabled="loading" />
|
||||||
</template>
|
</template>
|
||||||
</v-textarea>
|
</v-textarea>
|
||||||
|
|
||||||
|
<Attachments
|
||||||
|
ref="attachments"
|
||||||
|
v-model:show="dialogs.attachments"
|
||||||
|
v-model:uploading="uploading"
|
||||||
|
v-model:value="data.attachments"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-snackbar v-model="uploading" :timeout="-1">
|
||||||
|
Uploading your media, please stand by...
|
||||||
|
<v-progress-linear class="snackbar-progress" indeterminate />
|
||||||
|
</v-snackbar>
|
||||||
|
|
||||||
<!-- @vue-ignore -->
|
<!-- @vue-ignore -->
|
||||||
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
|
||||||
</v-form>
|
</v-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue"
|
import { reactive, ref } from "vue"
|
||||||
import { request } from "@/scripts/request"
|
import { request } from "@/scripts/request"
|
||||||
import { getAtk } from "@/stores/userinfo"
|
import { getAtk } from "@/stores/userinfo"
|
||||||
import { useChannels } from "@/stores/channels"
|
import { useChannels } from "@/stores/channels"
|
||||||
|
import Attachments from "@/components/chat/parts/Attachments.vue"
|
||||||
|
import Media from "@/components/publish/parts/Media.vue"
|
||||||
|
|
||||||
const emits = defineEmits(["sent"])
|
const emits = defineEmits(["sent"])
|
||||||
|
|
||||||
@ -36,8 +67,15 @@ const chat = ref<HTMLFormElement>()
|
|||||||
const channels = useChannels()
|
const channels = useChannels()
|
||||||
|
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const uploading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const attachments = ref<any>()
|
||||||
|
|
||||||
|
const dialogs = reactive({
|
||||||
|
attachments: false
|
||||||
|
})
|
||||||
|
|
||||||
const data = ref<any>({
|
const data = ref<any>({
|
||||||
content: "",
|
content: "",
|
||||||
attachments: []
|
attachments: []
|
||||||
@ -54,9 +92,33 @@ async function sendMessage() {
|
|||||||
error.value = await res.text()
|
error.value = await res.text()
|
||||||
} else {
|
} else {
|
||||||
emits("sent")
|
emits("sent")
|
||||||
chat.value?.reset()
|
resetEditor()
|
||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
chat.value?.reset()
|
||||||
|
data.value = {
|
||||||
|
content: "",
|
||||||
|
attachments: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteMedia(evt: ClipboardEvent) {
|
||||||
|
const files = evt.clipboardData?.files
|
||||||
|
if (files) {
|
||||||
|
Array.from(files).forEach((item) => {
|
||||||
|
attachments.value.upload(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.snackbar-progress {
|
||||||
|
margin: 12px -16px -14px;
|
||||||
|
width: calc(100% + 64px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -12,11 +12,19 @@
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</div>
|
<div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</div>
|
||||||
<div>{{ props.item?.content }}</div>
|
<div>{{ props.item?.content }}</div>
|
||||||
|
|
||||||
|
<message-attachment
|
||||||
|
v-if="props.item?.attachments && props.item?.attachments.length > 0"
|
||||||
|
class="mt-1"
|
||||||
|
:attachments="props.item?.attachments"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue"
|
||||||
|
|
||||||
const props = defineProps<{ item: any }>()
|
const props = defineProps<{ item: any }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
141
src/components/chat/parts/Attachments.vue
Normal file
141
src/components/chat/parts/Attachments.vue
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
eager
|
||||||
|
class="max-w-[540px]"
|
||||||
|
:model-value="props.show"
|
||||||
|
@update:model-value="(val) => emits('update:show', val)"
|
||||||
|
>
|
||||||
|
<v-card title="Attachments">
|
||||||
|
<template #text>
|
||||||
|
<v-file-input
|
||||||
|
prepend-icon=""
|
||||||
|
append-icon="mdi-upload"
|
||||||
|
variant="solo-filled"
|
||||||
|
label="File Picker"
|
||||||
|
v-model="picked"
|
||||||
|
:loading="props.uploading"
|
||||||
|
@click:append="upload()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 class="px-2 mb-1">Media list</h2>
|
||||||
|
<v-card variant="tonal">
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="(item, idx) in props.value" :title="getFileName(item)">
|
||||||
|
<template #subtitle> {{ getFileType(item) }} · {{ formatBytes(item.filesize) }}</template>
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click="dispose(idx)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<v-btn class="ms-auto" text="Ok" @click="emits('update:show', false)"></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { request } from "@/scripts/request"
|
||||||
|
import { getAtk } from "@/stores/userinfo"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ show: boolean; uploading: boolean; value: any[] }>()
|
||||||
|
const emits = defineEmits(["update:show", "update:uploading", "update:value"])
|
||||||
|
|
||||||
|
const picked = ref<any[]>([])
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function upload(file?: any) {
|
||||||
|
if (props.uploading) return
|
||||||
|
|
||||||
|
const data = new FormData()
|
||||||
|
if (!file) {
|
||||||
|
file = picked.value[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
data.set("attachment", file)
|
||||||
|
data.set("hashcode", await calculateHashCode(file))
|
||||||
|
|
||||||
|
emits("update:uploading", true)
|
||||||
|
const res = await request("messaging", "/api/attachments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${await getAtk()}` },
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
let meta: any
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
} else {
|
||||||
|
meta = await res.json()
|
||||||
|
emits("update:value", props.value.concat([meta.info]))
|
||||||
|
picked.value = []
|
||||||
|
}
|
||||||
|
emits("update:uploading", false)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispose(idx: number) {
|
||||||
|
const media = JSON.parse(JSON.stringify(props.value))
|
||||||
|
const item = media.splice(idx)[0]
|
||||||
|
emits("update:value", media)
|
||||||
|
|
||||||
|
const res = await request("messaging", `/api/attachments/${item.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${await getAtk()}` }
|
||||||
|
})
|
||||||
|
if (res.status !== 200) {
|
||||||
|
error.value = await res.text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ upload, dispose })
|
||||||
|
|
||||||
|
async function calculateHashCode(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
const buffer = reader.result as ArrayBuffer
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||||
|
resolve(hashHex)
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(reader.error)
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(item: any) {
|
||||||
|
return item.filename.replace(/\.[^/.]+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileType(item: any) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 1:
|
||||||
|
return "Photo"
|
||||||
|
case 2:
|
||||||
|
return "Video"
|
||||||
|
case 3:
|
||||||
|
return "Audio"
|
||||||
|
default:
|
||||||
|
return "Others"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number, decimals = 2) {
|
||||||
|
if (!+bytes) return "0 Bytes"
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
</script>
|
97
src/components/chat/renderer/MessageAttachment.vue
Normal file
97
src/components/chat/renderer/MessageAttachment.vue
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<v-chip size="small" variant="tonal" prepend-icon="mdi-paperclip" v-if="props.overview">
|
||||||
|
Attached {{ props.attachments.length }} attachment(s)
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<v-responsive v-else :aspect-ratio="16 / 9" max-height="720">
|
||||||
|
<v-card variant="outlined" class="w-full h-full">
|
||||||
|
<v-carousel
|
||||||
|
hide-delimiter-background
|
||||||
|
height="100%"
|
||||||
|
:hide-delimiters="props.attachments.length <= 1"
|
||||||
|
:show-arrows="false"
|
||||||
|
>
|
||||||
|
<v-carousel-item v-for="(item, idx) in attachments">
|
||||||
|
<img
|
||||||
|
v-if="item.type === 1"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="cursor-zoom-in content-visibility-auto w-full h-full object-contain"
|
||||||
|
:src="getUrl(item)"
|
||||||
|
:alt="item.filename"
|
||||||
|
@click="openLightbox(item, idx)"
|
||||||
|
/>
|
||||||
|
<video v-else-if="item.type === 2" controls class="w-full content-visibility-auto">
|
||||||
|
<source :src="getUrl(item)" />
|
||||||
|
</video>
|
||||||
|
<div v-else-if="item.type === 3" class="w-full px-7 py-12">
|
||||||
|
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full px-7 py-12">
|
||||||
|
<div class="text-center">
|
||||||
|
<p>{{ item.filename }}</p>
|
||||||
|
<a class="underline" target="_blank" :href="getUrl(item)">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-carousel-item>
|
||||||
|
</v-carousel>
|
||||||
|
|
||||||
|
<vue-easy-lightbox
|
||||||
|
teleport="#app"
|
||||||
|
:visible="lightbox"
|
||||||
|
:imgs="props.attachments.map((x) => getUrl(x))"
|
||||||
|
v-model:index="currentIndex"
|
||||||
|
@hide="lightbox = false"
|
||||||
|
>
|
||||||
|
<template v-slot:close-btn="{ close }">
|
||||||
|
<v-btn
|
||||||
|
class="fixed left-2 top-2"
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
color="white"
|
||||||
|
:style="`margin-top: ${safeAreaTop}`"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</vue-easy-lightbox>
|
||||||
|
</v-card>
|
||||||
|
</v-responsive>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { buildRequestUrl } from "@/scripts/request"
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { useUI } from "@/stores/ui"
|
||||||
|
import VueEasyLightbox from "vue-easy-lightbox"
|
||||||
|
|
||||||
|
const props = defineProps<{ attachments: any[]; overview?: boolean }>()
|
||||||
|
|
||||||
|
const ui = useUI()
|
||||||
|
|
||||||
|
const lightbox = ref(false)
|
||||||
|
|
||||||
|
const current = ref<any>(null)
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
|
||||||
|
const safeAreaTop = computed(() => {
|
||||||
|
return `${ui.safeArea.top}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
function getUrl(item: any) {
|
||||||
|
return item.external_url
|
||||||
|
? item.external_url
|
||||||
|
: buildRequestUrl("messaging", `/api/attachments/o/${item.file_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(item: any, idx: number) {
|
||||||
|
current.value = item
|
||||||
|
currentIndex.value = idx
|
||||||
|
lightbox.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vel-model {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
</style>
|
@ -262,10 +262,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.snackbar-progress {
|
.snackbar-progress {
|
||||||
margin-left: -16px;
|
margin: 12px -16px -14px;
|
||||||
margin-right: -16px;
|
|
||||||
margin-bottom: -14px;
|
|
||||||
margin-top: 12px;
|
|
||||||
width: calc(100% + 64px);
|
width: calc(100% + 64px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -188,10 +188,7 @@ watch(
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.snackbar-progress {
|
.snackbar-progress {
|
||||||
margin-left: -16px;
|
margin: 12px -16px -14px;
|
||||||
margin-right: -16px;
|
|
||||||
margin-bottom: -14px;
|
|
||||||
margin-top: 12px;
|
|
||||||
width: calc(100% + 64px);
|
width: calc(100% + 64px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
Reference in New Issue
Block a user