10 Commits

Author SHA1 Message Date
73b1e376a3 Channel manage 2024-03-31 01:06:06 +08:00
012a02751c Channel establish 2024-03-31 00:38:13 +08:00
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
38 changed files with 1145 additions and 184 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<v-menu eager :close-on-content-click="false"> <v-menu eager :close-on-content-click="false">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn v-bind="props" stacked rounded="circle" size="small" variant="text" :loading="loading"> <v-btn v-bind="props" icon rounded="circle" size="small" variant="text" :loading="loading">
<v-badge v-if="pagination.total > 0" color="error" :content="pagination.total"> <v-badge v-if="pagination.total > 0" color="error" :content="pagination.total">
<v-icon icon="mdi-bell" /> <v-icon icon="mdi-bell" />
</v-badge> </v-badge>

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,36 @@
<template>
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" icon="mdi-cog" variant="text" />
</template>
<v-list density="compact" lines="one">
<v-list-item disabled append-icon="mdi-flag" title="Report" />
<v-list-item v-if="isOwned" append-icon="mdi-pencil" title="Edit" @click="editChannel" />
<v-list-item v-if="isOwned" append-icon="mdi-delete" title="Delete" @click="deleteChannel" />
</v-list>
</v-menu>
</template>
<script setup lang="ts">
import { useUserinfo } from "@/stores/userinfo"
import { useChannels } from "@/stores/channels"
import { computed } from "vue"
const id = useUserinfo()
const channels = useChannels()
const props = defineProps<{ item: any }>()
const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.messaging)
function editChannel() {
channels.related.edit_to = props.item
channels.show.editor = true
}
function deleteChannel() {
channels.related.delete_to = props.item
channels.show.delete = true
}
</script>

View File

@@ -0,0 +1,61 @@
<template>
<v-card title="Delete a realm" class="min-h-[540px]" :loading="loading">
<template #text>
You are deleting a channel
<b>{{ channels.related.delete_to?.name }}</b> <br />
All messaging belonging to this channel will be deleted and never appear again. Are you confirm?
</template>
<template #actions>
<div class="w-full flex justify-end">
<v-btn color="grey-darken-3" @click="channels.show.delete = false">Not really</v-btn>
<v-btn color="error" :disabled="loading" @click="deletePost">Yes</v-btn>
</div>
</template>
</v-card>
<v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar>
<!-- @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 { getAtk } from "@/stores/userinfo"
import { useChannels } from "@/stores/channels"
import { useRoute, useRouter } from "vue-router"
import { ref } from "vue"
const route = useRoute()
const router = useRouter()
const channels = useChannels()
const emits = defineEmits(["relist"])
const error = ref<string | null>(null)
const success = ref(false)
const loading = ref(false)
async function deletePost() {
const target = channels.related.delete_to
const url = `/api/channels/${target.id}`
loading.value = true
const res = await request("messaging", url, {
method: "DELETE",
headers: { Authorization: `Bearer ${await getAtk()}` }
})
if (res.status !== 200) {
error.value = await res.text()
} else {
success.value = true
channels.show.delete = false
channels.related.delete_to = null
emits("relist")
if (route.name?.toString()?.startsWith("realm")) {
router.push({ name: "explore" })
}
}
loading.value = false
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<v-card title="Establish a channel" prepend-icon="mdi-pound-box" class="min-h-[540px]" :loading="loading">
<v-form @submit.prevent="submit">
<v-card-text>
<v-text-field label="Alias" variant="outlined" density="comfortable" hint="Must be unique"
v-model="data.alias" />
<v-text-field label="Name" variant="outlined" density="comfortable" v-model="data.name" />
<v-textarea label="Description" variant="outlined" density="comfortable" v-model="data.description" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn type="reset" color="grey-darken-3" @click="channels.show.editor = false">Cancel</v-btn>
<v-btn type="submit" :disabled="loading">Save</v-btn>
</v-card-actions>
</v-form>
</v-card>
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { getAtk } from "@/stores/userinfo"
import { request } from "@/scripts/request"
import { useChannels } from "@/stores/channels"
const emits = defineEmits(["relist"])
const channels = useChannels()
const error = ref<null | string>(null)
const loading = ref(false)
const data = ref({
alias: "",
name: "",
description: ""
})
async function submit(evt: SubmitEvent) {
const form = evt.target as HTMLFormElement
const payload = data.value
if (!payload.name) return
const url = channels.related.edit_to ? `/api/channels/${channels.related.edit_to?.id}` : "/api/channels"
const method = channels.related.edit_to ? "PUT" : "POST"
loading.value = true
const res = await request("messaging", url, {
method: method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
body: JSON.stringify(payload)
})
if (res.status !== 200) {
error.value = await res.text()
} else {
emits("relist")
form.reset()
channels.done = true
channels.show.editor = false
channels.related.edit_to = null
}
loading.value = false
}
watch(
channels.related,
(val) => {
if (val.edit_to) {
data.value = JSON.parse(JSON.stringify(val.edit_to))
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,42 @@
<template>
<v-list-group value="channels">
<template #activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-chat"
title="Channels"
/>
</template>
<v-list-item
v-for="item in channels.available"
exact
append-icon="mdi-pound-box"
:to="{ name: 'chat.channel', params: { channel: item.alias } }"
:title="item.name"
/>
<v-list-item
append-icon="mdi-plus"
title="Create a channel"
variant="plain"
:disabled="!id.userinfo.isLoggedIn"
@click="createChannel"
/>
</v-list-group>
</template>
<script setup lang="ts">
import { useUserinfo } from "@/stores/userinfo"
import { useRealms } from "@/stores/realms"
import { useChannels } from "@/stores/channels"
const id = useUserinfo()
const channels = useChannels()
function createChannel() {
channels.related.edit_to = null
channels.related.delete_to = null
channels.show.editor = true
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.editor">
<channel-editor @relist="channels.list" />
</v-bottom-sheet>
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.delete">
<channel-deletion @relist="channels.list" />
</v-bottom-sheet>
</template>
<script setup lang="ts">
import { useChannels } from "@/stores/channels"
import ChannelEditor from "@/components/chat/channels/ChannelEditor.vue"
import ChannelDeletion from "@/components/chat/channels/ChannelDeletion.vue"
const channels = useChannels()
</script>

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,33 +3,53 @@
Attached {{ props.attachments.length }} attachment(s) Attached {{ props.attachments.length }} attachment(s)
</v-chip> </v-chip>
<v-card v-else variant="outlined" class="max-w-[540px] max-h-[720px]"> <v-responsive v-else :aspect-ratio="16 / 9" max-height="720">
<v-carousel hide-delimiter-background height="100%" :show-arrows="false"> <v-card variant="outlined" class="w-full h-full">
<v-carousel-item v-for="item in attachments"> <v-carousel
<img v-if="item.type === 1" :src="getUrl(item)" :alt="item.filename" class="cursor-zoom-in" hide-delimiter-background
@click="openLightbox" /> height="100%"
<video v-if="item.type === 2" controls class="w-full"> :hide-delimiters="props.attachments.length <= 1"
<source :src="getUrl(item)" /> :show-arrows="false"
</video> >
<div v-if="item.type === 3" class="w-full px-7 py-12"> <v-carousel-item v-for="(item, idx) in attachments">
<audio controls :src="getUrl(item)" class="mx-auto"></audio> <img
</div> v-if="item.type === 1"
</v-carousel-item> loading="lazy"
</v-carousel> 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">
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
</div>
</v-carousel-item>
</v-carousel>
<vue-easy-lightbox teleport="#app" :visible="lightbox" :imgs="[getUrl(current)]" @hide="lightbox = false"> <vue-easy-lightbox
<template v-slot:close-btn="{ close }"> teleport="#app"
<v-btn :visible="lightbox"
class="fixed left-2 top-2" :imgs="props.attachments.map((x) => getUrl(x))"
icon="mdi-close" v-model:index="currentIndex"
variant="text" @hide="lightbox = false"
color="white" >
:style="`margin-top: ${safeAreaTop}`" <template v-slot:close-btn="{ close }">
@click="close" <v-btn
/> class="fixed left-2 top-2"
</template> icon="mdi-close"
</vue-easy-lightbox> variant="text"
</v-card> color="white"
:style="`margin-top: ${safeAreaTop}`"
@click="close"
/>
</template>
</vue-easy-lightbox>
</v-card>
</v-responsive>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -43,23 +63,24 @@ const props = defineProps<{ attachments: any[]; overview?: boolean }>()
const ui = useUI() const ui = useUI()
const lightbox = ref(false) const lightbox = ref(false)
const focus = ref(0)
const current = computed(() => props.attachments[focus.value]) const current = ref<any>(null)
const canLightbox = computed(() => current.value.type === 1) const currentIndex = ref(0)
const safeAreaTop = computed(() => { const safeAreaTop = computed(() => {
return `${ui.safeArea.top}px` return `${ui.safeArea.top}px`
}) })
function getUrl(item: any) { 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() { function openLightbox(item: any, idx: number) {
if (canLightbox.value) { current.value = item
lightbox.value = true currentIndex.value = idx
} lightbox.value = true
} }
</script> </script>

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ const realms = useRealms()
const props = defineProps<{ item: any }>() const props = defineProps<{ item: any }>()
const isOwned = computed(() => props.item?.account_id === id.userinfo.data.id) const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.interactive)
function editRealm() { function editRealm() {
realms.related.edit_to = props.item realms.related.edit_to = props.item

View File

@@ -3,7 +3,7 @@
<template #text> <template #text>
You are deleting a realm You are deleting a realm
<b>{{ realms.related.delete_to?.name }}</b> <br /> <b>{{ realms.related.delete_to?.name }}</b> <br />
All posts belonging to this domain will be deleted and never appear again. Are you confirm? All posts belonging to this realm will be deleted and never appear again. Are you confirm?
</template> </template>
<template #actions> <template #actions>
<div class="w-full flex justify-end"> <div class="w-full flex justify-end">

View File

@@ -1,27 +1,29 @@
<template> <template>
<v-list density="comfortable"> <v-list-group value="realms">
<v-list-subheader> <template #activator="{ props }">
Realms <v-list-item
<v-badge color="warning" content="Alpha" inline /> v-bind="props"
</v-list-subheader> prepend-icon="mdi-account-box-multiple"
title="Realms"
/>
</template>
<v-list-item <v-list-item
v-for="item in realms.available" v-for="item in realms.available"
exact exact
prepend-icon="mdi-account-multiple" append-icon="mdi-account-multiple"
:to="{ name: 'realms.page', params: { realmId: item.id } }" :to="{ name: 'realms.page', params: { realmId: item.id } }"
:title="item.name" :title="item.name"
/> />
<v-divider v-if="realms.available.length > 0" class="border-opacity-75 my-2" />
<v-list-item <v-list-item
prepend-icon="mdi-plus" append-icon="mdi-plus"
title="Create a realm" title="Create a realm"
variant="plain"
:disabled="!id.userinfo.isLoggedIn" :disabled="!id.userinfo.isLoggedIn"
@click="createRealm" @click="createRealm"
/> />
</v-list> </v-list-group>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,8 +1,8 @@
<template> <template>
<v-bottom-sheet v-model="realms.show.editor"> <v-bottom-sheet class="max-w-[480px]" v-model="realms.show.editor">
<realm-editor @relist="realms.list" /> <realm-editor @relist="realms.list" />
</v-bottom-sheet> </v-bottom-sheet>
<v-bottom-sheet v-model="realms.show.delete"> <v-bottom-sheet class="max-w-[480px]" v-model="realms.show.delete">
<realm-deletion @relist="realms.list" /> <realm-deletion @relist="realms.list" />
</v-bottom-sheet> </v-bottom-sheet>
</template> </template>

View File

@@ -3,6 +3,9 @@
<v-system-bar v-show="ui.safeArea.top > 0" color="primary" :order="1" :height="ui.safeArea.top" /> <v-system-bar v-show="ui.safeArea.top > 0" color="primary" :order="1" :height="ui.safeArea.top" />
<router-view /> <router-view />
<realm-tools />
<channel-tools />
</v-app> </v-app>
</template> </template>
@@ -10,6 +13,8 @@
import { onMounted, ref } from "vue" import { onMounted, ref } from "vue"
import { Capacitor } from "@capacitor/core" import { Capacitor } from "@capacitor/core"
import { useUI } from "@/stores/ui" import { useUI } from "@/stores/ui"
import RealmTools from "@/components/realms/RealmTools.vue"
import ChannelTools from "@/components/chat/channels/ChannelTools.vue"
const ui = useUI() const ui = useUI()

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

@@ -0,0 +1,65 @@
<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>
<v-spacer />
<div v-if="channels.current">
<channel-action :item="channels.current" />
</div>
</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"
import ChannelAction from "@/components/chat/channels/ChannelAction.vue"
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 }
)
watch(() => channels.done, (val) => {
if (val) {
readMetadata().then(() => {
channels.messages = []
channels.done = false
})
}
}, { immediate: true })
</script>

View File

@@ -6,33 +6,46 @@
:rail="drawerMini" :rail="drawerMini"
:rail-width="58" :rail-width="58"
:order="0" :order="0"
floating
@click="drawerMini = false" @click="drawerMini = false"
> >
<div class="flex flex-col h-full"> <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]" <v-toolbar
style="border-bottom-width: thin" class="flex items-center justify-between px-[14px] border-opacity-15"
:style="`padding-top: max(${safeAreaTop}, 10px)`"> color="primary"
height="64"
:style="`padding-top: ${safeAreaTop}`"
>
<div class="flex items-center"> <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-show="!drawerMini" class="ms-6 font-medium">Solar Network</div>
</div> </div>
<v-btn <v-spacer />
v-show="!drawerMini"
icon="mdi-arrow-collapse-left"
size="small"
variant="text"
@click.stop="drawerMini = true"
/>
</div>
<div class="flex-grow-1"> <div>
<v-btn
v-show="!drawerMini"
icon="mdi-arrow-collapse-left"
size="small"
variant="text"
@click.stop="drawerMini = true"
/>
</div>
</v-toolbar>
<v-list class="flex-grow-1" :opened="drawerMini ? [] : expanded" @update:opened="(val) => expanded = val">
<channel-list />
<v-divider class="border-opacity-75 my-2" />
<realm-list /> <realm-list />
</div> </v-list>
<!-- User info --> <!-- User info -->
<v-list class="border-opacity-15 h-[64px]" style="border-top-width: thin" <v-list
:style="`margin-bottom: ${safeAreaBottom}`"> class="border-opacity-15 h-[64px]"
style="border-top-width: thin"
:style="`margin-bottom: ${safeAreaBottom}`"
>
<v-list-item :subtitle="username" :title="nickname"> <v-list-item :subtitle="username" :title="nickname">
<template #prepend> <template #prepend>
<v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" /> <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" />
@@ -44,8 +57,12 @@
</template> </template>
<v-list density="compact"> <v-list density="compact">
<v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" target="_blank" <v-list-item
:href="passportUrl" /> title="Solarpass"
prepend-icon="mdi-passport-biometric"
target="_blank"
:href="passportUrl"
/>
</v-list> </v-list>
</v-menu> </v-menu>
@@ -75,36 +92,19 @@
<v-main id="main"> <v-main id="main">
<router-view /> <router-view />
</v-main> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { useEditor } from "@/stores/editor"
import { useUserinfo } from "@/stores/userinfo" import { useUserinfo } from "@/stores/userinfo"
import { useWellKnown } from "@/stores/wellKnown" import { useWellKnown } from "@/stores/wellKnown"
import { useUI } from "@/stores/ui" import { useUI } from "@/stores/ui"
import PostTools from "@/components/publish/PostTools.vue"
import RealmTools from "@/components/realms/RealmTools.vue"
import RealmList from "@/components/realms/RealmList.vue" import RealmList from "@/components/realms/RealmList.vue"
import NotificationList from "@/components/NotificationList.vue" import NotificationList from "@/components/NotificationList.vue"
import ChannelList from "@/components/chat/channels/ChannelList.vue"
const ui = useUI() const ui = useUI()
const expanded = ref<string[]>(["channels"])
const safeAreaTop = computed(() => { const safeAreaTop = computed(() => {
return `${ui.safeArea.top}px` return `${ui.safeArea.top}px`
@@ -115,7 +115,6 @@ const safeAreaBottom = computed(() => {
}) })
const id = useUserinfo() const id = useUserinfo()
const editor = useEditor()
const username = computed(() => { const username = computed(() => {
if (id.userinfo.isLoggedIn) { if (id.userinfo.isLoggedIn) {
@@ -146,10 +145,3 @@ const drawerOpen = ref(true)
const drawerMini = ref(false) const drawerMini = ref(false)
</script> </script>
<style scoped>
.editor-fab {
position: fixed !important;
bottom: 16px;
right: 20px;
}
</style>

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

@@ -0,0 +1,63 @@
<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 />
</template>
<script setup lang="ts">
import { useEditor } from "@/stores/editor"
import { useUserinfo } from "@/stores/userinfo"
import PostTools from "@/components/publish/PostTools.vue"
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

@@ -10,25 +10,43 @@ const router = createRouter({
children: [ children: [
{ {
path: "/", path: "/",
name: "explore", component: () => import("@/layouts/plaza.vue"),
component: () => import("@/views/explore.vue") children: [
{
path: "/",
name: "explore",
component: () => import("@/views/explore.vue")
},
{
path: "/p/moments/:alias",
name: "posts.details.moments",
component: () => import("@/views/posts/moments.vue")
},
{
path: "/p/articles/:alias",
name: "posts.details.articles",
component: () => import("@/views/posts/articles.vue")
},
{
path: "/realms/:realmId",
name: "realms.page",
component: () => import("@/views/realms/page.vue")
}
]
}, },
{ {
path: "/p/moments/:alias", path: "/chat/:channel",
name: "posts.details.moments", component: () => import("@/layouts/chat.vue"),
component: () => import("@/views/posts/moments.vue") children: [
}, {
{ path: "",
path: "/p/articles/:alias", name: "chat.channel",
name: "posts.details.articles", component: () => import("@/views/chat/page.vue"),
component: () => import("@/views/posts/articles.vue") }
}, ]
{
path: "/realms/:realmId",
name: "realms.page",
component: () => import("@/views/realms/page.vue")
}, },
{ {

View File

@@ -3,7 +3,8 @@ import { Preferences } from "@capacitor/preferences"
const serviceMap: { [id: string]: string } = { const serviceMap: { [id: string]: string } = {
interactive: "https://co.solsynth.dev", 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) { 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 = 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, available, current, messages, list, connect, disconnect }
})

View File

@@ -11,7 +11,7 @@ export const useRealms = defineStore("realms", () => {
delete: false delete: false
}) })
const related_to = reactive<{ edit_to: any; delete_to: any }>({ const related = reactive<{ edit_to: any; delete_to: any }>({
edit_to: null, edit_to: null,
delete_to: null delete_to: null
}) })
@@ -31,7 +31,5 @@ export const useRealms = defineStore("realms", () => {
} }
} }
list().then(() => console.log("[STARTUP HOOK] Fetch available realm successes.")) return { done, show, related, available, list }
return { done, show, related: related_to, available, list }
}) })

View File

@@ -2,11 +2,14 @@ import { defineStore } from "pinia"
import { ref } from "vue" import { ref } from "vue"
import { request } from "@/scripts/request" import { request } from "@/scripts/request"
import { Preferences } from "@capacitor/preferences" import { Preferences } from "@capacitor/preferences"
import { useRealms } from "@/stores/realms"
import { useChannels } from "@/stores/channels"
export interface Userinfo { export interface Userinfo {
isReady: boolean isReady: boolean
isLoggedIn: boolean isLoggedIn: boolean
displayName: string displayName: string
idSet: { [id: string]: number }
data: any data: any
} }
@@ -14,6 +17,7 @@ const defaultUserinfo: Userinfo = {
isReady: false, isReady: false,
isLoggedIn: false, isLoggedIn: false,
displayName: "Citizen", displayName: "Citizen",
idSet: {},
data: null data: null
} }
@@ -30,6 +34,10 @@ export async function checkLoggedIn(): Promise<boolean> {
} }
export const useUserinfo = defineStore("userinfo", () => { export const useUserinfo = defineStore("userinfo", () => {
const userinfoHooks = {
after: [useRealms().list, useChannels().list, useChannels().connect]
}
const userinfo = ref(defaultUserinfo) const userinfo = ref(defaultUserinfo)
const isReady = ref(false) const isReady = ref(false)
@@ -41,19 +49,33 @@ export const useUserinfo = defineStore("userinfo", () => {
const res = await request("identity", "/api/users/me", { const res = await request("identity", "/api/users/me", {
headers: { Authorization: `Bearer ${await getAtk()}` } headers: { Authorization: `Bearer ${await getAtk()}` }
}) })
if (res.status !== 200) { if (res.status !== 200) {
return return
} }
const data = await res.json() const data = await res.json()
const federationResp = await Promise.all([
request("interactive", "/api/users/me", {
headers: { Authorization: `Bearer ${await getAtk()}` }
}),
request("messaging", "/api/users/me", {
headers: { Authorization: `Bearer ${await getAtk()}` }
})
])
userinfo.value = { userinfo.value = {
isReady: true, isReady: true,
isLoggedIn: true, isLoggedIn: true,
displayName: data["nick"], displayName: data["nick"],
idSet: {
interactive: (await federationResp[0].json())["id"],
messaging: (await federationResp[1].json())["id"]
},
data: data data: data
} }
userinfoHooks.after.forEach((call) => call())
} }
return { userinfo, isReady, readProfiles } return { userinfo, isReady, readProfiles }

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

@@ -0,0 +1,88 @@
<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) {
pagination.page = 1
pagination.total = 0
readHistory()
}
},
{ immediate: true }
)
function scrollTop() {
window.scroll({ top: 0 })
}
</script>

View File

@@ -105,6 +105,8 @@ watch(
() => route.params.realmId, () => route.params.realmId,
() => { () => {
posts.value = [] posts.value = []
pagination.page = 1
pagination.total = 0
readMetadata() readMetadata()
readPosts() readPosts()
}, },

View File

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