24 Commits

Author SHA1 Message Date
LittleSheep
169b5c0209 Chat replying 2024-03-31 23:31:30 +08:00
LittleSheep
276c4f5dfe 🍱 Modify cache glob 2024-03-31 22:35:39 +08:00
LittleSheep
18d70382ff 💄 Optimize image browsing again 2024-03-31 22:17:30 +08:00
LittleSheep
09154f1359 Messages update & deletion 2024-03-31 20:35:36 +08:00
LittleSheep
fc1aef6eb7 🐛 Bug fixes 2024-03-31 18:38:37 +08:00
LittleSheep
5e2c6e6c3b 🐛 Bug fixes 2024-03-31 18:26:24 +08:00
LittleSheep
ff9f9b574b 🐛 Fix notify 2024-03-31 18:11:45 +08:00
LittleSheep
86b7fd85af 🐛 Optimize icon 2024-03-31 18:10:52 +08:00
LittleSheep
e0995b312c 🐛 Fix maskable icon 2024-03-31 18:05:22 +08:00
LittleSheep
e9e80bdeb5 🐛 Fix icon 2024-03-31 18:00:09 +08:00
LittleSheep
a8119e8366 🐛 Fix safe area issue 2024-03-31 17:54:27 +08:00
LittleSheep
43aad8c2d2 Local Notifications 2024-03-31 17:49:31 +08:00
LittleSheep
c3bfb2069c 🚚 Update request service map 2024-03-31 01:43:33 +08:00
LittleSheep
fbf45dab57 Channels member manage 2024-03-31 01:18:25 +08:00
LittleSheep
73b1e376a3 Channel manage 2024-03-31 01:06:06 +08:00
LittleSheep
012a02751c Channel establish 2024-03-31 00:38:13 +08:00
LittleSheep
634fedf17c Chat attachments 2024-03-31 00:07:04 +08:00
LittleSheep
a5efec89f2 Chat message send and read history 2024-03-30 23:21:22 +08:00
LittleSheep
8bb9816cd0 Basic chat layouts 2024-03-30 21:26:22 +08:00
LittleSheep
05e8782557 💄 Optimized attachments view 2024-03-30 20:24:26 +08:00
LittleSheep
e986ff8c5f 💄 Better speed dial 2024-03-30 20:08:39 +08:00
LittleSheep
c616214c3b 💄 Better navbar 2024-03-30 19:24:19 +08:00
LittleSheep
f552cdcf74 🐛 Bug fixes & optimization 2024-03-30 18:52:03 +08:00
LittleSheep
d187ca0a88 Add full PWA support 2024-03-30 12:06:19 +08:00
54 changed files with 1757 additions and 227 deletions

2
.gitignore vendored
View File

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

View File

@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-preferences')
}

View File

@@ -2,5 +2,8 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')

View File

@@ -1,13 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/xml+svg" href="/favicon.png" />
<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>
<head>
<meta charset="UTF-8" />
<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>
<style>
html, body {
scroll-behavior: smooth;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -27,6 +27,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
730477372BB91A4200A78988 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
@@ -73,6 +74,7 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
730477372BB91A4200A78988 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -345,6 +347,7 @@
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;
@@ -367,6 +370,7 @@
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = W7HPZ53V6B;

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:solsynth.dev</string>
</array>
</dict>
</plist>

View File

@@ -20,8 +20,21 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string></string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Allow Solian use your camera so that you can take photo for your post.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allow Solian full access your photo library so that you can share photos more easily.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow Solian access your photo library so that you can share photos.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -43,15 +56,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSApplicationCategoryType</key>
<string></string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allow Solian full access your photo library so that you can share photos more easily.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow Solian access your photo library so that you can share photos.</string>
<key>NSCameraUsageDescription</key>
<string>Allow Solian use your camera so that you can take photo for your post.</string>
</dict>
</plist>

View File

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
end

View File

@@ -2,12 +2,15 @@ PODS:
- Capacitor (5.7.4):
- CapacitorCordova
- CapacitorCordova (5.7.4)
- CapacitorLocalNotifications (5.0.7):
- Capacitor
- CapacitorPreferences (5.0.7):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
EXTERNAL SOURCES:
@@ -15,14 +18,17 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorLocalNotifications:
:path: "../../node_modules/@capacitor/local-notifications"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
SPEC CHECKSUMS:
Capacitor: 4fe9adf012caceb4c71ffea2f1f4d005cdcbeea7
CapacitorCordova: a6e87fccc0307dee7aec1560ec9398485f2b0ce7
CapacitorLocalNotifications: c58afadd159f6bc540ef9b3cbdbc82510a2bf112
CapacitorPreferences: 77ac427e98db83bace772455f8ba447430382c4c
PODFILE CHECKSUM: 769e120bf4dfe4ef1095b83775e36bafeeeb3cdd
PODFILE CHECKSUM: 19c3106e1cb0c8c0ae26243bfb70b974f8cfaaf5
COCOAPODS: 1.15.1

View File

@@ -16,6 +16,7 @@
"@capacitor/android": "^5.7.4",
"@capacitor/core": "^5.7.4",
"@capacitor/ios": "^5.7.4",
"@capacitor/local-notifications": "^5.0.7",
"@capacitor/preferences": "^5.0.7",
"@fontsource/roboto": "^5.0.12",
"@mdi/font": "^7.4.47",

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: 85 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"
}
]
}

BIN
public/maskable-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

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

@@ -1,8 +1,8 @@
<template>
<v-menu eager :close-on-content-click="false">
<template #activator="{ props }">
<v-btn v-bind="props" stacked rounded="circle" size="small" variant="text" :loading="loading">
<v-badge v-if="pagination.total > 0" color="error" :content="pagination.total">
<v-btn v-bind="props" icon size="small" variant="text" :loading="loading">
<v-badge v-if="notify.total > 0" color="error" :content="notify.total">
<v-icon icon="mdi-bell" />
</v-badge>
@@ -10,20 +10,19 @@
</v-btn>
</template>
<v-list v-if="notifications.length <= 0" class="w-[380px]" density="compact">
<v-list v-if="notify.notifications.length <= 0" class="w-[380px]" density="compact">
<v-list-item>
<v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for
you.</v-alert>
<v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for you.</v-alert>
</v-list-item>
</v-list>
<v-list v-else class="w-[380px]" density="compact" lines="three">
<v-list-item v-for="item in notifications">
<v-list-item v-for="(item, idx) in notify.notifications">
<template #title>{{ item.subject }}</template>
<template #subtitle>{{ item.content }}</template>
<template #append>
<v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item)" />
<v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item, idx)" />
</template>
<div class="flex text-xs gap-1">
@@ -40,50 +39,32 @@
<script setup lang="ts">
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
import { reactive, ref } from "vue"
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useNotifications } from "@/stores/notifications";
const loading = ref(false)
const notify = useNotifications()
const error = ref<string | null>(null)
const submitting = ref(false)
const loading = computed(() => notify.loading || submitting.value)
const notifications = ref<any[]>([])
const pagination = reactive({ page: 1, pageSize: 25, total: 0 })
async function readNotifications() {
loading.value = true
const res = await request(
"identity",
"/api/notifications?" +
new URLSearchParams({
take: pagination.pageSize.toString(),
offset: ((pagination.page - 1) * pagination.pageSize).toString()
}),
{
headers: { Authorization: `Bearer ${await getAtk()}` }
}
)
if (res.status === 200) {
const data = await res.json()
notifications.value = data["data"]
pagination.total = data["count"]
}
loading.value = false
}
readNotifications()
async function markAsRead(item: any) {
loading.value = true
async function markAsRead(item: any, idx: number) {
submitting.value = true
const res = await request("identity", `/api/notifications/${item.id}/read`, {
method: "PUT",
headers: { Authorization: `Bearer ${await getAtk()}` }
headers: { Authorization: `Bearer ${getAtk()}` },
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await readNotifications()
notify.remove(idx)
error.value = null
}
loading.value = false
submitting.value = false
}
notify.list()
onMounted(() => notify.connect())
onUnmounted(() => notify.disconnect())
</script>

View File

@@ -0,0 +1,212 @@
<template>
<v-form class="flex-grow-1" ref="chat" @submit.prevent="sendMessage">
<v-expand-transition>
<v-alert
v-show="channels.related?.messages?.reply_to"
class="mb-3 text-sm"
variant="tonal"
density="compact"
type="info"
>
You are about replying a message #{{ channels.related?.messages?.reply_to?.id }}
<template #prepend>
<div class="h-[30px] flex items-center justify-center">
<v-icon icon="mdi-reply" size="small" />
</div>
</template>
<template #append>
<v-btn
icon="mdi-close"
size="x-small"
color="info"
variant="text"
@click="channels.related.messages.reply_to = null"
/>
</template>
</v-alert>
</v-expand-transition>
<v-expand-transition>
<v-alert
v-show="channels.related?.messages?.edit_to"
class="mb-3 text-sm"
variant="tonal"
density="compact"
type="info"
>
You are about editing a message #{{ channels.related?.messages?.edit_to?.id }}
<template #prepend>
<div class="h-[30px] flex items-center justify-center">
<v-icon icon="mdi-pencil" size="small" />
</div>
</template>
<template #append>
<v-btn
icon="mdi-close"
size="x-small"
color="info"
variant="text"
@click="channels.related.messages.edit_to = null"
/>
</template>
</v-alert>
</v-expand-transition>
<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, watch } from "vue"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
import { useChannels } from "@/stores/channels"
import Attachments from "@/components/chat/parts/ChatAttachments.vue"
import Media from "@/components/publish/parts/PublishMedia.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: "",
reply_id: null,
attachments: []
})
async function sendMessage() {
if (!data.value.content) return
const url = channels.related.messages.edit_to
? `/api/channels/${channels.current.alias}/messages/${channels.related.messages.edit_to?.id}`
: `/api/channels/${channels.current.alias}/messages`
const method = channels.related.messages.edit_to ? "PUT" : "POST"
const payload = data.value
payload.reply_to = payload.reply_id
loading.value = true
const res = await request("messaging", url, {
method: method,
headers: { Authorization: `Bearer ${await getAtk()}`, "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
if (res.status !== 200) {
error.value = await res.text()
} else {
emits("sent")
resetEditor()
error.value = null
}
loading.value = false
}
watch(
() => channels.related.messages.reply_to,
(val) => {
if (val) {
data.value.reply_id = val.id
}
}
)
watch(
() => channels.related.messages.edit_to,
(val) => {
if (val) {
data.value = val
}
}
)
function resetEditor() {
chat.value?.reset()
channels.related.messages.reply_to = null
channels.related.messages.edit_to = null
channels.related.messages.delete_to = null
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.id">
<chat-message class="px-6 py-2" :id="`m${item.id}`" :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,89 @@
<template>
<div class="relative transition-colors transition-300 message-item">
<a v-if="props.item?.reply_to" :href="`#m${props.item?.reply_to.id}`">
<div class="pl-2 mb-0.5 text-sm opacity-80 flex items-center">
<v-icon icon="mdi-reply" class="me-2" />
<span class="me-1 text-xs overflow-hidden ws-nowarp text-ellipsis">{{ props.item?.reply_to?.content }}</span>
<span class="text-xs overflow-hidden ws-nowarp text-ellipsis">
from {{ props.item?.reply_to?.sender.account.name }}
</span>
</div>
</a>
<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 class="transition-opacity transition-300 message-action">
<v-card>
<div class="flex px-2 py-0.5">
<v-btn icon="mdi-reply" size="x-small" variant="text" @click="replyMessage" />
<v-btn icon="mdi-pencil" size="x-small" variant="text" color="warning" @click="editMessage" />
<v-btn icon="mdi-delete" size="x-small" variant="text" color="error" @click="deleteMessage" />
</div>
</v-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useChannels } from "@/stores/channels"
import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue"
const channels = useChannels()
const props = defineProps<{ item: any }>()
function replyMessage() {
channels.related.messages.reply_to = JSON.parse(JSON.stringify(props.item))
}
function editMessage() {
channels.related.messages.edit_to = JSON.parse(JSON.stringify(props.item))
}
function deleteMessage() {
channels.related.messages.delete_to = JSON.parse(JSON.stringify(props.item))
channels.related.messages.delete_to.channel = channels.current
channels.show.messages.delete = true
}
</script>
<style scoped>
.rounded-card {
border-radius: 8px;
}
.message-action {
position: absolute;
right: 8px;
top: -25%;
opacity: 0;
}
.message-item:hover {
background-color: rgba(0, 0, 0, .15);
}
.message-item:hover .message-action {
opacity: 100%;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<v-card title="Delete a message" class="min-h-[540px]" :loading="loading">
<template #text>
You are deleting a message
<b>#{{ channels.related?.messages?.delete_to?.id }}</b> <br />
This message will gone and never appear again. But the replies won't affected. Are you confirm?
</template>
<template #actions>
<div class="w-full flex justify-end">
<v-btn color="grey-darken-3" @click="channels.show.messages.delete = false">Not really</v-btn>
<v-btn color="error" :disabled="loading" @click="deleteMessage">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 { ref } from "vue"
const channels = useChannels()
const error = ref<string | null>(null)
const success = ref(false)
const loading = ref(false)
async function deleteMessage() {
const target = channels.related.messages.delete_to
const url = `/api/channels/${target.channel.alias}/messages/${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.messages.delete = false
channels.related.messages.delete_to = null
}
loading.value = false
}
</script>

View File

@@ -0,0 +1,42 @@
<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-account-supervisor-circle" title="Members" @click="manageChannel" />
<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 manageChannel() {
channels.related.manage_to = props.item
channels.show.members = 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="deleteChannel">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 deleteChannel() {
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()?.includes("channel")) {
await 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,54 @@
<template>
<v-card prepend-icon="mdi-account-plus" title="Invite someone">
<v-form @submit.prevent="inviteMember">
<v-card-text>
<v-text-field
label="Username"
variant="outlined"
density="comfortable"
hint="Require username not the nickname"
v-model="targetName"
/>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn type="reset" color="grey-darken-3" @click="emits('close')">Cancel</v-btn>
<v-btn type="submit" :disabled="loading">Invite</v-btn>
</v-card-actions>
</v-form>
</v-card>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
const props = defineProps<{ item: any }>()
const emits = defineEmits(["close", "error", "relist"])
const loading = ref(false)
const targetName = ref("")
async function inviteMember(evt: SubmitEvent) {
const form = evt.target as HTMLFormElement
loading.value = true
const res = await request("messaging", `/api/channels/${props.item?.id}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
body: JSON.stringify({
account_name: targetName.value
})
})
if (res.status !== 200) {
emits("error", await res.text())
} else {
form.reset()
emits("relist")
}
loading.value = false
}
</script>

View File

@@ -0,0 +1,43 @@
<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.manage_to = null
channels.related.delete_to = null
channels.show.editor = true
}
</script>

View File

@@ -0,0 +1,124 @@
<template>
<v-card title="Channel members" class="min-h-[540px]">
<v-list density="comfortable" lines="one">
<v-list-item v-for="item in members" :title="item.account.nick">
<template #subtitle>@{{ item.account.name }}</template>
<template #prepend>
<v-avatar
color="grey-lighten-2"
icon="mdi-account-circle"
class="rounded-card me-2"
size="small"
:image="item?.account.avatar"
/>
</template>
<template #append>
<v-btn
icon="mdi-account-remove"
size="x-small"
color="error"
variant="text"
:disabled="!checkKickable(item)"
@click="kickMember(item)"
/>
</template>
</v-list-item>
</v-list>
<div v-if="isOwned">
<v-divider class="mt-2 mb-3 border-opacity-50 mx-[-1rem]" />
<div class="px-3">
<v-dialog class="max-w-[540px]">
<template #activator="{ props }">
<v-btn v-bind="props" block prepend-icon="mdi-account-plus" variant="plain"> Invite someone </v-btn>
</template>
<template #default="{ isActive }">
<channel-invitation
:item="props.item"
@relist="listMembers"
@error="(val) => (error = val)"
@close="isActive.value = false"
/>
</template>
</v-dialog>
</div>
</div>
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</v-card>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { request } from "@/scripts/request"
import { getAtk, useUserinfo } from "@/stores/userinfo"
import { computed } from "vue"
import ChannelInvitation from "@/components/chat/channels/ChannelInvitation.vue"
const id = useUserinfo()
const props = defineProps<{ item: any }>()
const members = ref<any[]>([])
const isOwned = computed(() => {
return id.userinfo.idSet?.messaging === props.item?.account_id
})
const loading = ref(false)
const error = ref<string | null>(null)
watch(
() => props.item,
(val) => {
if (val?.id) {
listMembers(val.id)
}
},
{ deep: true, immediate: true }
)
async function listMembers(id: number) {
loading.value = true
const res = await request("messaging", `/api/channels/${id}/members`)
if (res.status !== 200) {
error.value = await res.text()
} else {
error.value = null
members.value = await res.json()
}
loading.value = false
}
async function kickMember(item: any) {
loading.value = true
const res = await request("messaging", `/api/channels/${props.item?.id}/kick`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` },
body: JSON.stringify({
account_name: item.account.name
})
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await listMembers(props.item?.id)
}
loading.value = false
}
function checkKickable(item: any) {
if (item.account?.id === id.userinfo.idSet?.messaging) return false
if (item.account?.id === props.item?.account_id) return false
return true
}
</script>
<style>
.rounded-card {
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,25 @@
<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.members">
<channel-members :item="channels.related.manage_to" @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>
<v-bottom-sheet class="max-w-[480px]" v-model="channels.show.messages.delete">
<message-deletion />
</v-bottom-sheet>
</template>
<script setup lang="ts">
import { useChannels } from "@/stores/channels"
import ChannelEditor from "@/components/chat/channels/ChannelEditor.vue"
import ChannelMembers from "@/components/chat/channels/ChannelMembers.vue"
import ChannelDeletion from "@/components/chat/channels/ChannelDeletion.vue"
import MessageDeletion from "@/components/chat/MessageDeletion.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,102 @@
<template>
<v-chip size="small" variant="tonal" prepend-icon="mdi-paperclip" v-if="props.overview">
Attached {{ props.attachments.length }} attachment(s)
</v-chip>
<v-card variant="outlined" class="w-fit max-h-[540px]">
<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 max-h-[540px] object-cover object-c"
: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-[480px] py-12">
<div class="text-center">
<p class="mb-1">{{ getFileName(item) }}</p>
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
</div>
</div>
<div v-else class="w-[480px] py-12">
<div class="text-center">
<p>{{ getFileName(item) }}</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>
</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 getFileName(item: any) {
return item.filename.replace(/\.[^/.]+$/, "")
}
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,21 +3,47 @@
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-card v-else variant="outlined" class="w-fit max-h-[540px]">
<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"
decoding="async"
class="cursor-zoom-in max-h-[540px] object-cover object-c"
:src="getUrl(item)"
:alt="getFileName(item)"
@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-if="item.type === 3" class="w-full px-7 py-12">
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
<div v-else-if="item.type === 3" class="w-[480px] py-12">
<div class="text-center">
<p class="mb-1">{{ getFileName(item) }}</p>
<audio controls :src="getUrl(item)" class="mx-auto"></audio>
</div>
</div>
<div v-else class="w-[480px] py-12">
<div class="text-center">
<p>{{ getFileName(item) }}</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="[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"
@@ -43,23 +69,28 @@ 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}`)
function getFileName(item: any) {
return item.filename.replace(/\.[^/.]+$/, "")
}
function openLightbox() {
if (canLightbox.value) {
lightbox.value = true
}
function getUrl(item: any) {
return item.external_url
? item.external_url
: buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`)
}
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">
<template v-for="(item, idx) in props.posts" :key="item.id">
<div class="mb-3 px-[8px]">
<v-card>
<template #text>
<post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />

View File

@@ -127,7 +127,7 @@ import { useRealms } from "@/stores/realms"
import { computed, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue"
import Media from "@/components/publish/parts/Media.vue"
import Media from "@/components/publish/parts/PublishMedia.vue"
import PublishArea from "@/components/publish/parts/PublishArea.vue"
const route = useRoute()
@@ -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

@@ -93,7 +93,7 @@ import { reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue"
import PublishArea from "@/components/publish/parts/PublishArea.vue"
import Media from "@/components/publish/parts/Media.vue"
import Media from "@/components/publish/parts/PublishMedia.vue"
const route = useRoute()
const editor = useEditor()
@@ -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>

View File

@@ -22,7 +22,7 @@ const realms = useRealms()
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() {
realms.related.edit_to = props.item

View File

@@ -3,12 +3,12 @@
<template #text>
You are deleting a realm
<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 #actions>
<div class="w-full flex justify-end">
<v-btn color="grey-darken-3" @click="realms.show.delete = false">Not really</v-btn>
<v-btn color="error" :disabled="loading" @click="deletePost">Yes</v-btn>
<v-btn color="error" :disabled="loading" @click="deleteRealm">Yes</v-btn>
</div>
</template>
</v-card>
@@ -36,7 +36,7 @@ const error = ref<string | null>(null)
const success = ref(false)
const loading = ref(false)
async function deletePost() {
async function deleteRealm() {
const target = realms.related.delete_to
const url = `/api/realms/${target.id}`
@@ -52,8 +52,8 @@ async function deletePost() {
realms.show.delete = false
realms.related.delete_to = null
emits("relist")
if (route.name?.toString()?.startsWith("realm")) {
router.push({ name: "explore" })
if (route.name?.toString()?.includes("realm")) {
await router.push({ name: "explore" })
}
}
loading.value = false

View File

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

View File

@@ -111,7 +111,7 @@ async function kickMember(item: any) {
}
function checkKickable(item: any) {
if (item.account?.id === id.userinfo.data?.id) return false
if (item.account?.id === id.userinfo.idSet?.interactive) return false
if (item.account?.id === props.item?.account_id) return false
return true
}

View File

@@ -1,8 +1,8 @@
<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" />
</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" />
</v-bottom-sheet>
</template>

View File

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

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

@@ -0,0 +1,72 @@
<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 max-w-full">
<v-app-bar-nav-icon icon="mdi-chat" :loading="loading" />
<h2 class="ml-2 text-lg font-500 overflow-hidden ws-nowrap text-clip">{{ channels.current?.name }}</h2>
<p class="ml-3 text-xs opacity-80 overflow-hidden ws-nowrap text-clip">{{ 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 { onMounted, 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,
(val) => {
if (val) {
channels.messages = []
readMetadata()
}
},
{ immediate: true }
)
watch(() => channels.done, (val) => {
if (val) {
readMetadata().then(() => {
channels.messages = []
channels.done = false
})
}
}, { immediate: true })
onMounted(() => {
channels.current = null
channels.messages = []
})
</script>

View File

@@ -6,33 +6,46 @@
: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-btn
v-show="!drawerMini"
icon="mdi-arrow-collapse-left"
size="small"
variant="text"
@click.stop="drawerMini = true"
/>
</div>
<v-spacer />
<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 />
</div>
</v-list>
<!-- User info -->
<v-list class="border-opacity-15 h-[64px]" style="border-top-width: thin"
:style="`margin-bottom: ${safeAreaBottom}`">
<v-list
class="border-opacity-15 h-[64px]"
style="border-top-width: thin"
:style="`margin-bottom: ${safeAreaBottom}`"
>
<v-list-item :subtitle="username" :title="nickname">
<template #prepend>
<v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" />
@@ -44,8 +57,12 @@
</template>
<v-list density="compact">
<v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" target="_blank"
:href="passportUrl" />
<v-list-item
title="Solarpass"
prepend-icon="mdi-passport-biometric"
target="_blank"
:href="passportUrl"
/>
</v-list>
</v-menu>
@@ -75,36 +92,19 @@
<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">
import { computed, ref } from "vue"
import { useEditor } from "@/stores/editor"
import { useUserinfo } from "@/stores/userinfo"
import { useWellKnown } from "@/stores/wellKnown"
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 NotificationList from "@/components/NotificationList.vue"
import ChannelList from "@/components/chat/channels/ChannelList.vue"
const ui = useUI()
const expanded = ref<string[]>(["channels"])
const safeAreaTop = computed(() => {
return `${ui.safeArea.top}px`
@@ -115,7 +115,6 @@ const safeAreaBottom = computed(() => {
})
const id = useUserinfo()
const editor = useEditor()
const username = computed(() => {
if (id.userinfo.isLoggedIn) {
@@ -146,10 +145,3 @@ const drawerOpen = ref(true)
const drawerMini = ref(false)
</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: [
{
path: "/",
name: "explore",
component: () => import("@/views/explore.vue")
component: () => import("@/layouts/plaza.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",
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: "/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: "https://im.solsynth.dev",
}
export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) {

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

@@ -0,0 +1,105 @@
import { defineStore } from "pinia"
import { reactive, ref, watch } from "vue"
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
import { buildRequestUrl, request } from "@/scripts/request"
import { useRoute } from "vue-router"
export const useChannels = defineStore("channels", () => {
let socket: WebSocket
const done = ref(false)
const show = reactive({
members: false,
editor: false,
delete: false,
messages: {
delete: false
}
})
const related = reactive<{ [id: string]: any }>({
edit_to: null,
manage_to: null,
delete_to: null,
messages: {
edit_to: null,
reply_to: null,
delete_to: null
}
})
const available = ref<any[]>([])
const current = ref<any>(null)
const messages = ref<any[]>([])
const route = useRoute()
watch(
() => route.params.channel,
(val) => {
if (!val) {
messages.value = []
current.value = null
}
},
{ immediate: true }
)
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)
break
case "messages.update":
messages.value = messages.value.map((x) => {
if (x.id === payload.id) return payload
else return x
})
break
case "messages.burnt":
messages.value = messages.value.filter((x) => {
return x.id !== payload.id
})
break
}
}
})
}
function disconnect() {
socket.close()
}
return { done, show, related, available, current, messages, list, connect, disconnect }
})

View File

@@ -0,0 +1,85 @@
import { defineStore } from "pinia"
import { ref } from "vue"
import { checkLoggedIn, getAtk } from "@/stores/userinfo"
import { buildRequestUrl, request } from "@/scripts/request"
import { LocalNotifications } from "@capacitor/local-notifications"
import { Capacitor } from "@capacitor/core"
export const useNotifications = defineStore("notifications", () => {
let socket: WebSocket
const loading = ref(false)
const notifications = ref<any[]>([])
const total = ref(0)
async function list() {
loading.value = true
const res = await request(
"identity",
"/api/notifications?" +
new URLSearchParams({
take: (25).toString(),
offset: (0).toString()
}),
{
headers: { Authorization: `Bearer ${await getAtk()}` }
}
)
if (res.status === 200) {
const data = await res.json()
notifications.value = data["data"]
total.value = data["count"]
}
loading.value = false
}
function remove(idx: number) {
notifications.value.splice(idx, 1)
total.value--
}
async function connect() {
if (!(await checkLoggedIn())) return
const uri = buildRequestUrl("identity", "/api/notifications/listen").replace("http", "ws")
socket = new WebSocket(uri + `?tk=${await getAtk() as string}`)
socket.addEventListener("open", (event) => {
console.log("[NOTIFICATIONS] The listen websocket has been established... ", event.type)
})
socket.addEventListener("close", (event) => {
console.warn("[NOTIFICATIONS] The listen websocket is disconnected... ", event.reason, event.code)
})
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
notifications.value.push(data)
total.value++
if (Capacitor.getPlatform() === "web") {
new Notification(data["subject"], {
body: data["content"]
})
} else {
LocalNotifications.schedule({
notifications: [
{ id: data["id"], title: data["subject"], body: data["content"] }
]
}).then((res) => console.log(res))
}
})
if (Capacitor.getPlatform() === "web") {
await Notification.requestPermission()
} else {
await LocalNotifications.requestPermissions()
}
}
function disconnect() {
socket.close()
}
return { loading, notifications, total, list, remove, connect, disconnect }
})

View File

@@ -11,7 +11,7 @@ export const useRealms = defineStore("realms", () => {
delete: false
})
const related_to = reactive<{ edit_to: any; delete_to: any }>({
const related = reactive<{ edit_to: any; delete_to: any }>({
edit_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: related_to, available, list }
return { done, show, related, available, list }
})

View File

@@ -2,11 +2,14 @@ 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
isLoggedIn: boolean
displayName: string
idSet: { [id: string]: number }
data: any
}
@@ -14,6 +17,7 @@ const defaultUserinfo: Userinfo = {
isReady: false,
isLoggedIn: false,
displayName: "Citizen",
idSet: {},
data: null
}
@@ -30,6 +34,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)
@@ -41,19 +49,33 @@ export const useUserinfo = defineStore("userinfo", () => {
const res = await request("identity", "/api/users/me", {
headers: { Authorization: `Bearer ${await getAtk()}` }
})
if (res.status !== 200) {
return
}
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 = {
isReady: true,
isLoggedIn: true,
displayName: data["nick"],
idSet: {
interactive: (await federationResp[0].json())["id"],
messaging: (await federationResp[1].json())["id"]
},
data: data
}
userinfoHooks.after.forEach((call) => call())
}
return { userinfo, isReady, readProfiles }

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

@@ -0,0 +1,101 @@
<template>
<v-container fluid class="px-0">
<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"
:style="`padding-bottom: max(${safeAreaBottom}, 8px)`"
>
<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 { useUI } from "@/stores/ui"
import { computed, onUnmounted, 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 ui = useUI()
const route = useRoute()
const channels = useChannels()
const safeAreaBottom = computed(() => {
return `${ui.safeArea.bottom}px`
})
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

@@ -4,7 +4,7 @@
<post-list v-model:posts="posts" :loader="readMore" />
</div>
<div class="aside md:sticky top-0 w-full h-fit md:min-w-[280px] md:max-w-[320px] max-md:order-first">
<div class="aside w-full h-full md:min-w-[320px] md:max-w-[320px] max-md:order-first">
<v-card title="Categories">
<v-list density="compact">
<v-list-item title="All" prepend-icon="mdi-apps" active></v-list-item>
@@ -16,8 +16,8 @@
<script setup lang="ts">
import PostList from "@/components/posts/PostList.vue"
import { reactive, ref } from "vue"
import { request } from "@/scripts/request"
import { reactive, ref } from "vue"
const error = ref<string | null>(null)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })

View File

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

View File

@@ -17,10 +17,29 @@ export default defineConfig({
useCredentials: true,
manifest: {
name: "Solian",
short_name: "Solian",
description: "The Solar Network entrypoint.",
description: "The Solar Network Application",
theme_color: "#4b5094",
display: "standalone",
icons: [
{
src: "icon.png",
sizes: "1024x1024",
type: "image/png",
purpose: "any"
},
{
src: "maskable-icon.png",
sizes: "1024x1024",
type: "image/png",
purpose: "maskable"
}
]
},
workbox: {
sourcemap: true,
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,ico,png,svg}'],
}
})
],
resolve: {