chat wip
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@ark-ui/vue": "^5.36.2",
|
||||
"@lucide/vue": "^1.14.0",
|
||||
"@tanstack/query-persist-client-core": "^5.100.10",
|
||||
"@tanstack/vue-query": "^5.100.10",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
|
||||
22
new-client/src/shared/api/qUser.ts
Normal file
22
new-client/src/shared/api/qUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ResponseError, User } from '@shared/api/generated-chad-api.ts'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import api from '@shared/api/client.ts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export function qUser(username: MaybeRefOrGetter<string>) {
|
||||
return useQuery<User, ResponseError>({
|
||||
queryKey: ['user', username],
|
||||
queryFn: async () => {
|
||||
const response = await api.chad.userGet({ id: toValue(username) })
|
||||
|
||||
return response.data
|
||||
},
|
||||
staleTime: Infinity,
|
||||
// persister: experimental_createQueryPersister({
|
||||
// storage: localStorage,
|
||||
// maxAge: 1000 * 60 * 60 * 24,
|
||||
// prefix: 'CHAD_USER_',
|
||||
// }),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="chad-avatar" v-bind="api.getRootProps()">
|
||||
<div class="chad-avatar" :class="{ highlighted }" v-bind="api.getRootProps()">
|
||||
<span class="chad-avatar__fallback" v-bind="api.getFallbackProps()">{{ fallback }}</span>
|
||||
<img class="chad-avatar__image" :alt="fallback" :src="src" v-bind="api.getImageProps()">
|
||||
</div>
|
||||
@@ -18,6 +18,7 @@ defineProps<{
|
||||
src: string
|
||||
fallback: string
|
||||
size?: 'sm'
|
||||
highlighted?: boolean
|
||||
}>()
|
||||
|
||||
const service = useMachine(avatar.machine, { id: useId() })
|
||||
@@ -27,24 +28,31 @@ const api = computed(() => avatar.connect(service, normalizeProps))
|
||||
|
||||
<style lang="scss">
|
||||
.chad-avatar {
|
||||
$self: &;
|
||||
|
||||
outline: var(--border-w) solid var(--ink);
|
||||
outline-offset: calc(var(--border-w) * -1);
|
||||
width: 40px;
|
||||
width: 32px;
|
||||
aspect-ratio: 1;
|
||||
|
||||
&__fallback {
|
||||
@include font-label;
|
||||
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: block;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
background-color: var(--grey-2);
|
||||
|
||||
#{$self}.highlighted & {
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
:type="type"
|
||||
:data-full="full || undefined"
|
||||
>
|
||||
<slot />
|
||||
<ChadSpinner v-if="loading" class="chad-button__spinner" />
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChadSpinner from '@shared/components/ui/Spinner.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChadButton',
|
||||
})
|
||||
@@ -61,7 +64,6 @@ interface Props {
|
||||
}
|
||||
|
||||
&[data-disabled],
|
||||
&[data-loading],
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--grey-1);
|
||||
@@ -69,6 +71,7 @@ interface Props {
|
||||
}
|
||||
|
||||
&[data-loading] {
|
||||
background-color: var(--grey-1);
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
|
||||
43
new-client/src/shared/components/ui/Spinner.vue
Normal file
43
new-client/src/shared/components/ui/Spinner.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="chad-spinner" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'ChadSpinner',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chad-spinner {
|
||||
--color1: var(--yellow);
|
||||
--color2: var(--ink);
|
||||
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 12px;
|
||||
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color1) 0%,
|
||||
var(--color1) 33.333%,
|
||||
transparent 33.333%,
|
||||
transparent 66.666%,
|
||||
var(--color2) 66.666%,
|
||||
var(--color2) 100%
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
animation: chad-spinner 400ms infinite;
|
||||
}
|
||||
@keyframes chad-spinner {
|
||||
from {
|
||||
--color1: var(--yellow);
|
||||
--color2: var(--ink);
|
||||
}
|
||||
50% {
|
||||
--color1: var(--ink);
|
||||
--color2: var(--yellow);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
new-client/src/shared/composables/use-user-details.ts
Normal file
12
new-client/src/shared/composables/use-user-details.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { qUser } from '@shared/api/qUser.ts'
|
||||
import { readonly } from 'vue'
|
||||
|
||||
export function useUserDetails(username: MaybeRefOrGetter<string>) {
|
||||
const { data: user, isFetching } = qUser(username)
|
||||
|
||||
return {
|
||||
user: readonly(user),
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
<aside class="sidebar">
|
||||
<ChannelList class="sidebar__channel-list" />
|
||||
|
||||
<ChadButton style="margin: var(--space-2);" @click="logout">
|
||||
Logout
|
||||
</ChadButton>
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
@@ -29,7 +33,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChadButton from '@shared/components/ui/Button.vue'
|
||||
import { useApp } from '@shared/composables/use-app.js'
|
||||
import { useAuth } from '@shared/composables/use-auth.ts'
|
||||
import ChannelList from '@widgets/channel-list/ui/ChannelList.vue'
|
||||
|
||||
defineOptions({
|
||||
@@ -37,6 +43,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { version } = useApp()
|
||||
const { logout } = useAuth()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -15,15 +15,16 @@ export function qChatMessages() {
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await api.chad.chatMessages({ cursor: pageParam, limit: 30 })
|
||||
|
||||
return {
|
||||
messages: response.data.messages.reverse(),
|
||||
nextCursor: response.data.nextCursor,
|
||||
}
|
||||
return response.data
|
||||
// return {
|
||||
// messages: response.data.messages.reverse(),
|
||||
// nextCursor: response.data.nextCursor,
|
||||
// }
|
||||
},
|
||||
select: data => ({
|
||||
pages: [...data.pages].reverse(),
|
||||
pageParams: [...data.pageParams].reverse(),
|
||||
}),
|
||||
// select: data => ({
|
||||
// pages: [...data.pages].reverse(),
|
||||
// pageParams: [...data.pageParams].reverse(),
|
||||
// }),
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
return lastPage.nextCursor
|
||||
|
||||
16
new-client/src/widgets/chat/api/qMessageAttachment.ts
Normal file
16
new-client/src/widgets/chat/api/qMessageAttachment.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ResponseError } from '@shared/api/generated-chad-api.ts'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import api from '@shared/api/client.ts'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export function qMessageAttachment(attachmentId: MaybeRefOrGetter<string>) {
|
||||
return useQuery<Blob, ResponseError>({
|
||||
queryKey: ['message-attachment', attachmentId],
|
||||
queryFn: async () => {
|
||||
const response = await api.chad.attachmentGet(toValue(attachmentId), { format: 'blob' })
|
||||
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { createGlobalState } from '@vueuse/core'
|
||||
import { qChatMessages } from '../api/qChatMessages.ts'
|
||||
|
||||
export const useChat = createGlobalState(() => {
|
||||
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage } = qChatMessages()
|
||||
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage, isFetching } = qChatMessages()
|
||||
|
||||
return {
|
||||
messages,
|
||||
hasMoreMessages,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
<template>
|
||||
<div class="chat">
|
||||
<button v-if="hasMoreMessages" type="button" @click="fetchNextPage()">
|
||||
Load more
|
||||
</button>
|
||||
|
||||
<div v-if="messages" class="chat__messages">
|
||||
<template v-for="(page, pageIdx) in messages.pages" :key="pageIdx">
|
||||
<ChatMessage v-for="message in page.messages" :key="message.id" :message="message" />
|
||||
</template>
|
||||
</div>
|
||||
<ChatMessages class="chat__messages" />
|
||||
<ChatInput class="chat__input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue'
|
||||
import { useChat } from '../composables/use-chat'
|
||||
|
||||
const { messages, hasMoreMessages, fetchNextPage } = useChat()
|
||||
import ChatInput from '@widgets/chat/ui/ChatInput.vue'
|
||||
import ChatMessages from '@widgets/chat/ui/ChatMessages.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&__messages {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
16
new-client/src/widgets/chat/ui/ChatInput.vue
Normal file
16
new-client/src/widgets/chat/ui/ChatInput.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<ChadInput placeholder="Write a message..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChadInput from '@shared/components/ui/Input.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-input {
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-top: var(--border-w) solid var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,72 @@
|
||||
<template>
|
||||
<div class="chat-message">
|
||||
<ChadAvatar class="chat-message__avatar" src="" :fallback="initials" />
|
||||
<ChadAvatar class="chat-message__avatar" src="" :fallback="initials" :highlighted="isMyMessage" />
|
||||
<div>
|
||||
<div class="chat-message__top">
|
||||
<p class="chat-message__sender">
|
||||
{{ message.senderId }}
|
||||
{{ !sender ? message.senderId : sender.displayName }}
|
||||
</p>
|
||||
|
||||
<p class="chat-message__datetime">
|
||||
{{ datetime }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="chat-message__body">
|
||||
{{ message.text }}
|
||||
</p>
|
||||
|
||||
<div v-if="message.attachments.length > 0" class="chat-message__attachments">
|
||||
<ChatMessageAttachment v-for="attachmentId in message.attachments" :key="attachmentId" :attachment-id="attachmentId" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
|
||||
import ChadAvatar from '@shared/components/ui/Avatar.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useAuth } from '@shared/composables/use-auth.ts'
|
||||
import { useUserDetails } from '@shared/composables/use-user-details.ts'
|
||||
import ChatMessageAttachment from '@widgets/chat/ui/ChatMessageAttachment.vue'
|
||||
import { format, isThisYear, isToday } from 'date-fns'
|
||||
import { computed, toRef } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ChatMessage
|
||||
}>()
|
||||
|
||||
const { me } = useAuth()
|
||||
|
||||
const isMyMessage = computed(() => {
|
||||
return props.message.senderId === me.value?.id
|
||||
})
|
||||
|
||||
const { user: sender, isFetching: fetchingSender } = useUserDetails(toRef(() => props.message.senderId))
|
||||
|
||||
const initials = computed(() => props.message.senderId.slice(props.message.senderId.length - 2))
|
||||
|
||||
const datetime = computed(() => {
|
||||
let formatStr = 'd MMMM yyyy, HH:mm'
|
||||
|
||||
if (isToday(props.message.createdAt)) {
|
||||
formatStr = 'HH:mm'
|
||||
}
|
||||
else if (isThisYear(props.message.createdAt)) {
|
||||
formatStr = 'd MMMM, HH:mm'
|
||||
}
|
||||
|
||||
return format(props.message.createdAt, formatStr)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message {
|
||||
$self: &;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: 'avatar sender' 'avatar body';
|
||||
grid-template-areas: 'avatar top' 'avatar body';
|
||||
column-gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
|
||||
@@ -34,14 +74,36 @@ const initials = computed(() => props.message.senderId.slice(props.message.sende
|
||||
grid-area: avatar;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
grid-area: top;
|
||||
//margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
&__sender {
|
||||
@include font-body-bold;
|
||||
}
|
||||
|
||||
grid-area: sender;
|
||||
&__datetime {
|
||||
@include font-micro;
|
||||
|
||||
flex-shrink: 0;
|
||||
color: var(--grey-3);
|
||||
}
|
||||
|
||||
&__body {
|
||||
grid-area: body;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
margin-top: var(--space-2);
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
46
new-client/src/widgets/chat/ui/ChatMessageAttachment.vue
Normal file
46
new-client/src/widgets/chat/ui/ChatMessageAttachment.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="chat-message-attachment">
|
||||
<p v-if="isFetching">
|
||||
Loading...
|
||||
</p>
|
||||
<template v-else-if="attachment">
|
||||
<img v-if="isImage" :src="src" :alt="attachmentId" draggable="false">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qMessageAttachment } from '@widgets/chat/api/qMessageAttachment.ts'
|
||||
import { computed, toRef } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
attachmentId: string
|
||||
}>()
|
||||
|
||||
const { isFetching, data: attachment } = qMessageAttachment(toRef(() => props.attachmentId))
|
||||
|
||||
const isImage = computed(() => {
|
||||
return attachment.value?.type.startsWith('image/')
|
||||
})
|
||||
|
||||
const src = computed(() => {
|
||||
if (!attachment.value || !isImage.value)
|
||||
return undefined
|
||||
|
||||
return URL.createObjectURL(attachment.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message-attachment {
|
||||
outline: var(--border-w) solid var(--ink);
|
||||
outline-offset: calc(var(--border-w) * -1);
|
||||
background-color: var(--grey-1);
|
||||
width: fit-content;
|
||||
|
||||
> img {
|
||||
max-width: 260px;
|
||||
max-height: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
new-client/src/widgets/chat/ui/ChatMessages.vue
Normal file
59
new-client/src/widgets/chat/ui/ChatMessages.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="chat-messages">
|
||||
<div v-if="messages" ref="scroll" class="chat-messages__list">
|
||||
<template v-for="(page, pageIdx) in messages.pages" :key="pageIdx">
|
||||
<ChatMessage v-for="message in page.messages" :key="message.id" :message="message" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ChadSpinner v-if="isFetching" class="chat-messages__spinner" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChadSpinner from '@shared/components/ui/Spinner.vue'
|
||||
import { useInfiniteScroll } from '@vueuse/core'
|
||||
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useChat } from '../composables/use-chat'
|
||||
|
||||
const scrollRef = useTemplateRef('scroll')
|
||||
|
||||
const { messages, hasMoreMessages, fetchNextPage, isFetching } = useChat()
|
||||
|
||||
useInfiniteScroll(
|
||||
scrollRef,
|
||||
() => {
|
||||
fetchNextPage()
|
||||
},
|
||||
{
|
||||
distance: 500,
|
||||
direction: 'top',
|
||||
interval: 300,
|
||||
canLoadMore: () => hasMoreMessages.value && !isFetching.value,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-messages {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__spinner {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__list {
|
||||
overflow-y: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -818,6 +818,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4"
|
||||
integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==
|
||||
|
||||
"@tanstack/query-persist-client-core@^5.100.10":
|
||||
version "5.100.10"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.100.10.tgz#efe9ea2b7cea90330129afdf7393f09fd06ad358"
|
||||
integrity sha512-O9Pey40DhTTDBABS0bHr+KNL5/VMf6PrqjexS8WoDDtnkaoWM+y0MSe0V9E5W+BwvkjM33mB3aYcCxa175gZTQ==
|
||||
dependencies:
|
||||
"@tanstack/query-core" "5.100.10"
|
||||
|
||||
"@tanstack/vue-query@^5.100.10":
|
||||
version "5.100.10"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/vue-query/-/vue-query-5.100.10.tgz#eafed0d51887c12dcbba5edb1b7108522ebc3891"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Type } from 'typebox'
|
||||
|
||||
export const GetUserQuerySchema = Type.Partial(Type.Object({
|
||||
username: Type.String(),
|
||||
id: Type.String(),
|
||||
}), { $id: 'GetUserQuery' })
|
||||
|
||||
export const UserPreferencesSchema = Type.Object({
|
||||
|
||||
@@ -19,7 +19,7 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
|
||||
},
|
||||
async (req, reply) => {
|
||||
const user = await fastify.prisma.user.findFirst({
|
||||
where: { username: req.query.username },
|
||||
where: { username: req.query.username, id: req.query.id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
|
||||
Reference in New Issue
Block a user