8 Commits

Author SHA1 Message Date
634fedf17c Chat attachments 2024-03-31 00:07:04 +08:00
a5efec89f2 Chat message send and read history 2024-03-30 23:21:22 +08:00
8bb9816cd0 Basic chat layouts 2024-03-30 21:26:22 +08:00
05e8782557 💄 Optimized attachments view 2024-03-30 20:24:26 +08:00
e986ff8c5f 💄 Better speed dial 2024-03-30 20:08:39 +08:00
c616214c3b 💄 Better navbar 2024-03-30 19:24:19 +08:00
f552cdcf74 🐛 Bug fixes & optimization 2024-03-30 18:52:03 +08:00
d187ca0a88 Add full PWA support 2024-03-30 12:06:19 +08:00
26 changed files with 838 additions and 157 deletions

2
.gitignore vendored
View File

@@ -29,3 +29,5 @@ coverage
*.tsbuildinfo
*.lockb
*dist

View File

@@ -1,13 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/xml+svg" href="/favicon.png" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" type="image/png" href="/apple-touch-icon.png" sizes="1024x1024">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Solian</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<style>
html, body {
scroll-behavior: smooth;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,46 +0,0 @@
{
"icons": [
{
"src": "../icons/icon-48.webp",
"type": "image/png",
"sizes": "48x48",
"purpose": "any maskable"
},
{
"src": "../icons/icon-72.webp",
"type": "image/png",
"sizes": "72x72",
"purpose": "any maskable"
},
{
"src": "../icons/icon-96.webp",
"type": "image/png",
"sizes": "96x96",
"purpose": "any maskable"
},
{
"src": "../icons/icon-128.webp",
"type": "image/png",
"sizes": "128x128",
"purpose": "any maskable"
},
{
"src": "../icons/icon-192.webp",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "../icons/icon-256.webp",
"type": "image/png",
"sizes": "256x256",
"purpose": "any maskable"
},
{
"src": "../icons/icon-512.webp",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

View File

@@ -2,7 +2,6 @@ html,
body,
#app,
.v-application {
overflow: auto !important;
font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif;
}

View File

@@ -0,0 +1,124 @@
<template>
<v-form class="flex-grow-1" ref="chat" @submit.prevent="sendMessage">
<v-textarea
auto-grow
hide-details
class="w-full"
variant="outlined"
density="compact"
placeholder="Enter some messages..."
:rows="1"
:max-rows="6"
:loading="loading"
v-model="data.content"
@keyup.ctrl.enter="sendMessage"
@keyup.meta.enter="sendMessage"
@paste="pasteMedia"
>
<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" />
</template>
</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 -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</v-form>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
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 chat = ref<HTMLFormElement>()
const channels = useChannels()
const error = ref<string | null>(null)
const uploading = ref(false)
const loading = ref(false)
const attachments = ref<any>()
const dialogs = reactive({
attachments: false
})
const data = ref<any>({
content: "",
attachments: []
})
async function sendMessage() {
loading.value = true
const res = await request("messaging", `/api/channels/${channels.current.alias}/messages`, {
method: "POST",
headers: { Authorization: `Bearer ${await getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(data.value)
})
if (res.status !== 200) {
error.value = await res.text()
} else {
emits("sent")
resetEditor()
error.value = null
}
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>
<style>
.snackbar-progress {
margin: 12px -16px -14px;
width: calc(100% + 64px);
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<v-infinite-scroll
class="mt-[-16px] overflow-hidden"
:onLoad="props.loader"
>
<template v-for="item in props.messages" :key="item">
<chat-message class="mb-4" :item="item" />
</template>
<template #empty>
<div class="flex-grow-1"></div>
</template>
</v-infinite-scroll>
</template>
<script setup lang="ts">
import ChatMessage from "@/components/chat/ChatMessage.vue"
const props = defineProps<{ loader: (opts: any) => Promise<any>, messages: any[] }>()
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex gap-2">
<div>
<v-avatar
color="grey-lighten-2"
icon="mdi-account-circle"
class="rounded-card"
:image="props.item?.sender.account.avatar"
/>
</div>
<div class="flex-grow-1">
<div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</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>
</template>
<script setup lang="ts">
import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue"
const props = defineProps<{ item: any }>()
</script>
<style scoped>
.rounded-card {
border-radius: 8px;
}
</style>

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

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

View File

@@ -3,12 +3,25 @@
Attached {{ props.attachments.length }} attachment(s)
</v-chip>
<v-card v-else variant="outlined" class="max-w-[540px] max-h-[720px]">
<v-carousel hide-delimiter-background height="100%" :show-arrows="false">
<v-carousel-item v-for="item in attachments">
<img v-if="item.type === 1" :src="getUrl(item)" :alt="item.filename" class="cursor-zoom-in"
@click="openLightbox" />
<video v-if="item.type === 2" controls class="w-full">
<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-if="item.type === 2" controls class="w-full content-visibility-auto">
<source :src="getUrl(item)" />
</video>
<div v-if="item.type === 3" class="w-full px-7 py-12">
@@ -17,7 +30,13 @@
</v-carousel-item>
</v-carousel>
<vue-easy-lightbox teleport="#app" :visible="lightbox" :imgs="[getUrl(current)]" @hide="lightbox = false">
<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"
@@ -30,6 +49,7 @@
</template>
</vue-easy-lightbox>
</v-card>
</v-responsive>
</template>
<script setup lang="ts">
@@ -43,23 +63,24 @@ const props = defineProps<{ attachments: any[]; overview?: boolean }>()
const ui = useUI()
const lightbox = ref(false)
const focus = ref(0)
const current = computed(() => props.attachments[focus.value])
const canLightbox = computed(() => current.value.type === 1)
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("interactive", `/api/attachments/o/${item.file_id}`)
return item.external_url
? item.external_url
: buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`)
}
function openLightbox() {
if (canLightbox.value) {
function openLightbox(item: any, idx: number) {
current.value = item
currentIndex.value = idx
lightbox.value = true
}
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="post-list">
<div class="post-list mx-[-8px]">
<v-infinite-scroll :items="props.posts" :onLoad="props.loader">
<template v-for="(item, idx) in props.posts" :key="item">
<div class="mb-3 px-1">
<div class="mb-3 px-[8px]">
<v-card>
<template #text>
<post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />

View File

@@ -262,10 +262,7 @@ watch(
}
.snackbar-progress {
margin-left: -16px;
margin-right: -16px;
margin-bottom: -14px;
margin-top: 12px;
margin: 12px -16px -14px;
width: calc(100% + 64px);
}
</style>

View File

@@ -188,10 +188,7 @@ watch(
<style>
.snackbar-progress {
margin-left: -16px;
margin-right: -16px;
margin-bottom: -14px;
margin-top: 12px;
margin: 12px -16px -14px;
width: calc(100% + 64px);
}
</style>

50
src/layouts/chat.vue Normal file
View File

@@ -0,0 +1,50 @@
<template>
<v-app-bar :order="5" color="grey-lighten-3">
<div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center">
<v-app-bar-nav-icon icon="mdi-chat" :loading="loading" />
<h2 class="ml-2 text-lg font-500">{{ channels.current?.name }}</h2>
<p class="ml-3 text-xs opacity-80">{{ channels.current?.description }}</p>
</div>
</v-app-bar>
<router-view />
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</template>
<script setup lang="ts">
import { request } from "@/scripts/request"
import { useRoute } from "vue-router"
import { ref, watch } from "vue"
import { useChannels } from "@/stores/channels"
const route = useRoute()
const channels = useChannels()
const error = ref<string | null>(null)
const loading = ref(false)
async function readMetadata() {
loading.value = true
const res = await request("messaging", `/api/channels/${route.params.channel}`)
if (res.status !== 200) {
error.value = await res.text()
} else {
error.value = null
channels.current = await res.json()
}
loading.value = false
}
watch(
() => route.params.channel,
() => {
channels.messages = []
readMetadata()
},
{ immediate: true }
)
</script>

View File

@@ -6,17 +6,24 @@
:rail="drawerMini"
:rail-width="58"
:order="0"
floating
@click="drawerMini = false"
>
<div class="flex flex-col h-full">
<div class="flex items-center justify-between px-3 pb-2.5 border-opacity-15 min-h-[64px]"
style="border-bottom-width: thin"
:style="`padding-top: max(${safeAreaTop}, 10px)`">
<v-toolbar
class="flex items-center justify-between px-[14px] border-opacity-15"
color="primary"
height="64"
:style="`padding-top: ${safeAreaTop}`"
>
<div class="flex items-center">
<img src="/favicon.png" alt="Logo" width="36" height="36" class="block" />
<img src="/favicon.png" alt="Logo" width="32" height="32" class="block" />
<div v-show="!drawerMini" class="ms-6 font-medium">Solar Network</div>
</div>
<v-spacer />
<div>
<v-btn
v-show="!drawerMini"
icon="mdi-arrow-collapse-left"
@@ -25,6 +32,7 @@
@click.stop="drawerMini = true"
/>
</div>
</v-toolbar>
<div class="flex-grow-1">
<realm-list />
@@ -75,22 +83,6 @@
<v-main id="main">
<router-view />
</v-main>
<v-menu open-on-hover open-on-click :open-delay="0" :close-delay="0" location="top"
transition="scroll-y-reverse-transition">
<template v-slot:activator="{ props }">
<v-fab v-bind="props" appear class="editor-fab" icon="mdi-pencil" color="primary" size="64"
:active="id.userinfo.isLoggedIn" />
</template>
<div class="flex flex-col items-center gap-4 mb-4">
<v-btn variant="elevated" color="secondary" icon="mdi-newspaper-variant" @click="editor.show.article = true" />
<v-btn variant="elevated" color="accent" icon="mdi-camera-iris" @click="editor.show.moment = true" />
</div>
</v-menu>
<post-tools />
<realm-tools />
</template>
<script setup lang="ts">
@@ -115,7 +107,6 @@ const safeAreaBottom = computed(() => {
})
const id = useUserinfo()
const editor = useEditor()
const username = computed(() => {
if (id.userinfo.isLoggedIn) {
@@ -146,10 +137,3 @@ const drawerOpen = ref(true)
const drawerMini = ref(false)
</script>
<style scoped>
.editor-fab {
position: fixed !important;
bottom: 16px;
right: 20px;
}
</style>

65
src/layouts/plaza.vue Normal file
View File

@@ -0,0 +1,65 @@
<template>
<router-view />
<v-fab
appear
class="editor-fab"
color="primary"
size="64"
:active="id.userinfo.isLoggedIn"
>
<v-icon icon="mdi-pencil" />
<v-speed-dial
target=".editor-fab"
activator="parent"
location="top center"
class="editor-speed-dial"
transition="slide-y-reverse-transition"
open-on-hover
open-on-click
>
<v-btn
key="article"
variant="elevated"
color="secondary"
icon="mdi-newspaper-variant"
@click="editor.show.article = true"
/>
<v-btn
key="moment"
variant="elevated"
color="accent"
icon="mdi-camera-iris"
@click="editor.show.moment = true"
/>
</v-speed-dial>
</v-fab>
<post-tools />
<realm-tools />
</template>
<script setup lang="ts">
import PostTools from "@/components/publish/PostTools.vue"
import RealmTools from "@/components/realms/RealmTools.vue"
import { useEditor } from "@/stores/editor"
import { useUserinfo } from "@/stores/userinfo"
const id = useUserinfo()
const editor = useEditor()
</script>
<style scoped>
.editor-fab {
position: fixed !important;
bottom: 16px;
right: 20px;
}
.editor-speed-dial {
position: fixed !important;
bottom: 16px;
right: 20px;
}
</style>

View File

@@ -7,6 +7,10 @@ const router = createRouter({
{
path: "/",
component: MasterLayout,
children: [
{
path: "/",
component: () => import("@/layouts/plaza.vue"),
children: [
{
path: "/",
@@ -29,6 +33,20 @@ const router = createRouter({
path: "/realms/:realmId",
name: "realms.page",
component: () => import("@/views/realms/page.vue")
}
]
},
{
path: "/chat/:channel",
component: () => import("@/layouts/chat.vue"),
children: [
{
path: "",
name: "chat.channel",
component: () => import("@/views/chat/page.vue"),
}
]
},
{

View File

@@ -3,7 +3,8 @@ import { Preferences } from "@capacitor/preferences"
const serviceMap: { [id: string]: string } = {
interactive: "https://co.solsynth.dev",
identity: "https://id.solsynth.dev"
identity: "https://id.solsynth.dev",
messaging: "http://localhost:8447",
}
export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) {

68
src/stores/channels.ts Normal file
View File

@@ -0,0 +1,68 @@
import { defineStore } from "pinia"
import { reactive, ref } from "vue"
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
import { buildRequestUrl, request } from "@/scripts/request"
export const useChannels = defineStore("channels", () => {
let socket: WebSocket
const done = ref(false)
const show = reactive({
editor: false,
delete: false
})
const related_to = reactive<{ edit_to: any; delete_to: any }>({
edit_to: null,
delete_to: null
})
const available = ref<any[]>([])
const current = ref<any>(null)
const messages = ref<any[]>([])
async function list() {
if (!(await checkLoggedIn())) return
const res = await request("messaging", "/api/channels/me/available", {
headers: { Authorization: `Bearer ${await getAtk()}` }
})
if (res.status !== 200) {
throw new Error(await res.text())
} else {
available.value = await res.json()
}
}
async function connect() {
if (!(await checkLoggedIn())) return
const uri = buildRequestUrl("messaging", "/api/unified").replace("http", "ws")
socket = new WebSocket(uri + `?tk=${await getAtk() as string}`)
socket.addEventListener("open", (event) => {
console.log("[MESSAGING] The unified websocket has been established... ", event.type)
})
socket.addEventListener("close", (event) => {
console.warn("[MESSAGING] The unified websocket is disconnected... ", event.reason, event.code)
})
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
const payload = data["p"]
if (payload?.channel_id === current.value.id) {
switch (data["w"]) {
case "messages.new":
messages.value.unshift(payload)
}
}
})
}
function disconnect() {
socket.close()
}
return { done, show, related_to, available, current, messages, list, connect, disconnect }
})

View File

@@ -31,7 +31,5 @@ export const useRealms = defineStore("realms", () => {
}
}
list().then(() => console.log("[STARTUP HOOK] Fetch available realm successes."))
return { done, show, related: related_to, available, list }
})

View File

@@ -2,6 +2,8 @@ import { defineStore } from "pinia"
import { ref } from "vue"
import { request } from "@/scripts/request"
import { Preferences } from "@capacitor/preferences"
import { useRealms } from "@/stores/realms"
import { useChannels } from "@/stores/channels"
export interface Userinfo {
isReady: boolean
@@ -30,6 +32,10 @@ export async function checkLoggedIn(): Promise<boolean> {
}
export const useUserinfo = defineStore("userinfo", () => {
const userinfoHooks = {
after: [useRealms().list, useChannels().list, useChannels().connect]
}
const userinfo = ref(defaultUserinfo)
const isReady = ref(false)
@@ -54,6 +60,8 @@ export const useUserinfo = defineStore("userinfo", () => {
displayName: data["nick"],
data: data
}
userinfoHooks.after.forEach((call) => call())
}
return { userinfo, isReady, readProfiles }

84
src/views/chat/page.vue Normal file
View File

@@ -0,0 +1,84 @@
<template>
<v-container fluid class="px-6">
<div class="message-list">
<chat-list :loader="readMore" :messages="channels.messages" />
</div>
</v-container>
<v-footer app class="flex items-center border-opacity-15 min-h-[64px]" style="border-top-width: thin">
<chat-editor class="flex-grow-1" @sent="scrollTop" />
</v-footer>
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</template>
<script setup lang="ts">
import { useChannels } from "@/stores/channels"
import { request } from "@/scripts/request"
import { reactive, ref, watch } from "vue"
import { useRoute } from "vue-router"
import ChatList from "@/components/chat/ChatList.vue"
import ChatEditor from "@/components/chat/ChatEditor.vue"
const route = useRoute()
const channels = useChannels()
const chatList = ref<HTMLDivElement>()
const error = ref<string | null>(null)
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
async function readHistory() {
loading.value = true
const res = await request(
"messaging",
`/api/channels/${route.params.channel}/messages?` + new URLSearchParams({
take: pagination.pageSize.toString(),
offset: ((pagination.page - 1) * pagination.pageSize).toString()
})
)
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
pagination.total = data["count"]
channels.messages.push(...(data["data"] ?? []))
error.value = null
}
loading.value = false
}
async function readMore({ done }: any) {
// Reach the end of data
if (pagination.total === 0) {
done("ok")
return
}
if (pagination.total <= pagination.page * pagination.pageSize) {
done("empty")
return
}
pagination.page++
await readHistory()
if (error.value != null) done("error")
else {
if (pagination.total > 0) done("ok")
else done("empty")
}
}
watch(() => channels.current, (val) => {
if (val) {
readHistory()
}
}, { immediate: true })
function scrollTop() {
window.scroll({ top: 0 })
}
</script>

View File

@@ -16,10 +16,19 @@ export default defineConfig({
registerType: "autoUpdate",
useCredentials: true,
manifest: {
name: "Solian",
name: "Solar Network",
short_name: "Solian",
description: "The Solar Network entrypoint.",
theme_color: "#4b5094",
display: "standalone",
icons: [
{
src: "icon.png",
sizes: "1024x1024",
type: "image/png",
purpose: "maskable"
},
]
},
})
],