brutalism design
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import initializeApp from './bootstrap/app'
|
||||
import authorize from './bootstrap/authorize'
|
||||
import showError from './bootstrap/error'
|
||||
import setupMediasoup from './bootstrap/mediasoup'
|
||||
import preloader from './bootstrap/preloader'
|
||||
import connectSignaling from './bootstrap/signaling'
|
||||
import checkUpdates from './bootstrap/updater'
|
||||
|
||||
(async () => {
|
||||
@@ -13,6 +15,9 @@ import checkUpdates from './bootstrap/updater'
|
||||
|
||||
await authorize()
|
||||
|
||||
connectSignaling()
|
||||
setupMediasoup()
|
||||
|
||||
await initializeApp()
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Api } from './generated-chad-api'
|
||||
|
||||
const api = new Api({
|
||||
baseUrl: 'http://localhost:4000',
|
||||
baseApiParams: { credentials: 'include' },
|
||||
})
|
||||
|
||||
function isChadResponseError(error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface Attachment {
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
ownerId: string | null;
|
||||
ownerUsername: string | null;
|
||||
name: string;
|
||||
persistent: boolean;
|
||||
}
|
||||
@@ -42,15 +42,22 @@ export interface Channel {
|
||||
export interface ChatMessage {
|
||||
/** @format uuid */
|
||||
id: string;
|
||||
/** @format uuid */
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
/** @minLength 1 */
|
||||
text: string;
|
||||
/** @format date-time */
|
||||
createdAt: string;
|
||||
/** @format date-time */
|
||||
updatedAt: string;
|
||||
attachments: string[];
|
||||
attachments: {
|
||||
id: string;
|
||||
name: string;
|
||||
mimetype: string;
|
||||
/** @min 0 */
|
||||
size: number;
|
||||
/** @format date-time */
|
||||
createdAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +125,7 @@ export interface NewChatMessagePayload {
|
||||
export interface Reply {
|
||||
/** @format uuid */
|
||||
messageId: string;
|
||||
/** @format uuid */
|
||||
senderId: string;
|
||||
senderUsername: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -164,7 +170,6 @@ export interface UserPreferences {
|
||||
* User
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
/** @format date-time */
|
||||
@@ -232,7 +237,7 @@ export class HttpClient<SecurityDataType = unknown> {
|
||||
fetch(...fetchParams);
|
||||
|
||||
private baseApiParams: RequestParams = {
|
||||
credentials: "include",
|
||||
credentials: "same-origin",
|
||||
headers: {},
|
||||
redirect: "follow",
|
||||
referrerPolicy: "no-referrer",
|
||||
@@ -590,30 +595,8 @@ export class Api<
|
||||
* @summary Send message
|
||||
* @request POST:/chad/chat/send
|
||||
*/
|
||||
chatSend: (
|
||||
data: {
|
||||
/** @minLength 1 */
|
||||
text: string;
|
||||
attachments?: string[];
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<
|
||||
{
|
||||
/** @format uuid */
|
||||
id: string;
|
||||
/** @format uuid */
|
||||
senderId: string;
|
||||
/** @minLength 1 */
|
||||
text: string;
|
||||
/** @format date-time */
|
||||
createdAt: string;
|
||||
/** @format date-time */
|
||||
updatedAt: string;
|
||||
attachments: string[];
|
||||
},
|
||||
ResponseError
|
||||
>({
|
||||
chatSend: (data: NewChatMessagePayload, params: RequestParams = {}) =>
|
||||
this.request<ChatMessage, ResponseError>({
|
||||
path: `/chad/chat/send`,
|
||||
method: "POST",
|
||||
body: data,
|
||||
@@ -648,19 +631,7 @@ export class Api<
|
||||
) =>
|
||||
this.request<
|
||||
{
|
||||
messages: {
|
||||
/** @format uuid */
|
||||
id: string;
|
||||
/** @format uuid */
|
||||
senderId: string;
|
||||
/** @minLength 1 */
|
||||
text: string;
|
||||
/** @format date-time */
|
||||
createdAt: string;
|
||||
/** @format date-time */
|
||||
updatedAt: string;
|
||||
attachments: string[];
|
||||
}[];
|
||||
messages: ChatMessage[];
|
||||
/**
|
||||
* Cursor to last message
|
||||
* @format uuid
|
||||
|
||||
@@ -8,7 +8,7 @@ export function qUser(username: MaybeRefOrGetter<string>) {
|
||||
return useQuery<User, ResponseError>({
|
||||
queryKey: ['user', username],
|
||||
queryFn: async () => {
|
||||
const response = await api.chad.userGet({ id: toValue(username) })
|
||||
const response = await api.chad.userGet({ username: toValue(username) })
|
||||
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -46,6 +46,10 @@ const api = computed(() => avatar.connect(service, normalizeProps))
|
||||
#{$self}.highlighted & {
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
|
||||
&[data-state='hidden'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
@@ -53,6 +57,10 @@ const api = computed(() => avatar.connect(service, normalizeProps))
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
|
||||
&[data-state='hidden'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,7 @@ interface Props {
|
||||
.chad-button {
|
||||
@include font-display-14;
|
||||
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
padding-inline: var(--space-3);
|
||||
|
||||
47
new-client/src/shared/components/ui/Tag.vue
Normal file
47
new-client/src/shared/components/ui/Tag.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="chad-tag" :data-type="type">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'ChadTag',
|
||||
})
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chad-tag {
|
||||
@include font-micro;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--grey-3);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
&[data-type='success'] {
|
||||
&::before {
|
||||
background-color: var(--green);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-type='error'] {
|
||||
&::before {
|
||||
background-color: var(--red);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
new-client/src/shared/components/ui/Toggle.vue
Normal file
67
new-client/src/shared/components/ui/Toggle.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<button class="chad-toggle" v-bind="api.getRootProps()">
|
||||
<span class="chad-toggle__indicator" v-bind="api.getIndicatorProps()" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import * as toggle from '@zag-js/toggle'
|
||||
import { normalizeProps, useMachine } from '@zag-js/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChadToggle',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>('modelValue')
|
||||
|
||||
const service = useMachine(toggle.machine, computed(() => {
|
||||
return {
|
||||
disabled: props.disabled,
|
||||
pressed: modelValue.value,
|
||||
onPressedChange: (pressed) => {
|
||||
modelValue.value = pressed
|
||||
},
|
||||
} as toggle.Props
|
||||
}))
|
||||
|
||||
const api = computed(() => toggle.connect(service, normalizeProps))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chad-toggle {
|
||||
@include font-micro;
|
||||
|
||||
border: var(--border-w) solid var(--ink);
|
||||
height: 44px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--grey-2);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background-color: var(--yellow);
|
||||
}
|
||||
|
||||
&[data-state='off'] {
|
||||
background-color: var(--paper);
|
||||
color: var(--grey-3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--grey-1);
|
||||
color: var(--grey-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
new-client/src/shared/composables/use-clients.ts
Normal file
72
new-client/src/shared/composables/use-clients.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ChadClient } from '@shared/types.ts'
|
||||
import { useSignaling } from '@shared/composables/use-signaling.ts'
|
||||
import { createGlobalState } from '@vueuse/core'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { useAuth } from './use-auth'
|
||||
|
||||
export const useClients = createGlobalState(() => {
|
||||
const { me } = useAuth()
|
||||
const signaling = useSignaling()
|
||||
|
||||
const clients = shallowRef<ChadClient[]>([])
|
||||
|
||||
function addClient(...incoming: ChadClient[]) {
|
||||
const ids = new Set(incoming.map(c => c.socketId))
|
||||
clients.value = [
|
||||
...clients.value.filter(c => !ids.has(c.socketId)),
|
||||
...incoming,
|
||||
]
|
||||
}
|
||||
|
||||
function removeClient(...socketIds: string[]) {
|
||||
const ids = new Set(socketIds)
|
||||
clients.value = clients.value.filter(c => !ids.has(c.socketId))
|
||||
}
|
||||
|
||||
function updateClient(socketId: string, patch: Partial<Omit<ChadClient, 'socketId'>>) {
|
||||
clients.value = clients.value.map(c =>
|
||||
c.socketId === socketId ? { ...c, ...patch } : c,
|
||||
)
|
||||
}
|
||||
|
||||
function findById(socketId: string) {
|
||||
return clients.value.find(c => c.socketId === socketId)
|
||||
}
|
||||
|
||||
function findByChannel(channelId: string) {
|
||||
return clients.value.filter(c => c.channelId === channelId)
|
||||
}
|
||||
|
||||
function findByUsername(username: string) {
|
||||
return clients.value.find(c => c.username === username)
|
||||
}
|
||||
|
||||
const self = computed(() => {
|
||||
return clients.value.find((client) => {
|
||||
if (signaling.socket.value) {
|
||||
return client.socketId === signaling.socket.value.id
|
||||
}
|
||||
else if (me.value) {
|
||||
return client.username === me.value.username
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
|
||||
function clear() {
|
||||
clients.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
clients,
|
||||
self,
|
||||
addClient,
|
||||
removeClient,
|
||||
updateClient,
|
||||
findById,
|
||||
findByChannel,
|
||||
findByUsername,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
|
||||
import type { EventType } from 'mitt'
|
||||
import mitt from 'mitt'
|
||||
|
||||
@@ -29,7 +30,7 @@ export interface AppEvents extends Record<EventType, unknown> {
|
||||
'share:enabled': void
|
||||
'share:disabled': void
|
||||
|
||||
'chat:new-message': void
|
||||
'chat:new-message': ChatMessage
|
||||
}
|
||||
|
||||
const emitter = mitt<AppEvents>()
|
||||
|
||||
3
new-client/src/shared/composables/use-session.ts
Normal file
3
new-client/src/shared/composables/use-session.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function useSession() {
|
||||
return {}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useAuth } from './use-auth'
|
||||
import { useEventBus } from './use-event-bus'
|
||||
|
||||
export const useSignaling = createSharedComposable(() => {
|
||||
const { emit } = useEventBus()
|
||||
const eventBus = useEventBus()
|
||||
const { me } = useAuth()
|
||||
|
||||
const socket = shallowRef<Socket>()
|
||||
@@ -44,9 +44,9 @@ export const useSignaling = createSharedComposable(() => {
|
||||
|
||||
watch(connected, (connected) => {
|
||||
if (connected)
|
||||
emit('socket:connected')
|
||||
eventBus.emit('socket:connected')
|
||||
else
|
||||
emit('socket:disconnected')
|
||||
eventBus.emit('socket:disconnected')
|
||||
}, { immediate: true })
|
||||
|
||||
watch(me, (me) => {
|
||||
@@ -56,10 +56,7 @@ export const useSignaling = createSharedComposable(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
socket.value?.close()
|
||||
socket.value = undefined
|
||||
})
|
||||
onScopeDispose(disconnect)
|
||||
|
||||
function connect() {
|
||||
if (socket.value || !me.value)
|
||||
@@ -73,15 +70,18 @@ export const useSignaling = createSharedComposable(() => {
|
||||
path: `${pathname}/ws`,
|
||||
transports: ['websocket'],
|
||||
withCredentials: true,
|
||||
auth: {
|
||||
userId: me.value.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
socket.value?.disconnect()
|
||||
socket.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
connected,
|
||||
connect,
|
||||
disconnect,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,9 +21,22 @@
|
||||
<aside class="sidebar">
|
||||
<ChannelList class="sidebar__channel-list" />
|
||||
|
||||
<ChadButton style="margin: var(--space-2);" @click="logout">
|
||||
Logout
|
||||
</ChadButton>
|
||||
<!-- <dl> -->
|
||||
<!-- <template v-for="consumer in consumers.store.value.values()" :key="consumer.id"> -->
|
||||
<!-- <dt>{{ consumer.id }}</dt> -->
|
||||
<!-- <dd><strong>paused</strong> <i>{{ consumer.paused }}</i></dd> -->
|
||||
<!-- <dd><strong>appData</strong> <i>{{ consumer.appData }}</i></dd> -->
|
||||
<!-- </template> -->
|
||||
<!-- </dl> -->
|
||||
|
||||
<!-- <ChadButton style="margin: var(--space-2);" @click="toggleMic"> -->
|
||||
<!-- Toggle mic -->
|
||||
<!-- </ChadButton> -->
|
||||
|
||||
<!-- <ChadButton style="margin: var(--space-2);" @click="logout"> -->
|
||||
<!-- Logout -->
|
||||
<!-- </ChadButton> -->
|
||||
<ControlPanel />
|
||||
</aside>
|
||||
|
||||
<div class="content">
|
||||
@@ -33,17 +46,28 @@
|
||||
</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 { useMediasoup } from '@shared/composables/use-mediasoup.ts'
|
||||
import { useProducers } from '@shared/composables/use-producers.ts'
|
||||
import ChannelList from '@widgets/channel-list/ui/ChannelList.vue'
|
||||
import ControlPanel from '@widgets/control-panel/ui/ControlPanel.vue'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DefaultLayout',
|
||||
})
|
||||
|
||||
const { version } = useApp()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const { sendTransport } = useMediasoup()
|
||||
const { enableMic } = useProducers()
|
||||
|
||||
watchEffect(() => {
|
||||
if (!sendTransport.value)
|
||||
return
|
||||
|
||||
enableMic()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -35,9 +35,18 @@ a {
|
||||
color: var(--yellow);
|
||||
}
|
||||
}
|
||||
//
|
||||
//*,
|
||||
//*::before,
|
||||
//*::after {
|
||||
// @include font-body;
|
||||
//}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
color: var(--grey-2);
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--grey-2);
|
||||
//border-right: 4px solid var(--paper);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export interface ChadClient {
|
||||
socketId: string
|
||||
userId: string
|
||||
username: string
|
||||
channelId: string
|
||||
inputMuted?: boolean
|
||||
outputMuted?: boolean
|
||||
micMuted?: boolean
|
||||
soundMuted?: boolean
|
||||
streaming?: boolean
|
||||
}
|
||||
|
||||
@@ -5,26 +5,38 @@
|
||||
</div>
|
||||
|
||||
<div v-bind="api.getContentProps()" class="channel__content">
|
||||
Clients...
|
||||
<p v-for="client in clients" :key="client.socketId">
|
||||
{{ client.username }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Channel } from '@shared/api/generated-chad-api.ts'
|
||||
import type { ChadClient } from '@shared/types.ts'
|
||||
import { useClients } from '@shared/composables/use-clients.ts'
|
||||
import * as collapsible from '@zag-js/collapsible'
|
||||
import { normalizeProps, useMachine } from '@zag-js/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
channel: Channel
|
||||
clients: ChadClient[]
|
||||
}>()
|
||||
|
||||
const service = useMachine(collapsible.machine, { id: '1' })
|
||||
const { findByChannel } = useClients()
|
||||
|
||||
const service = useMachine(collapsible.machine, computed(() => {
|
||||
return {
|
||||
id: props.channel.id,
|
||||
defaultOpen: true,
|
||||
}
|
||||
}))
|
||||
|
||||
const api = computed(() => collapsible.connect(service, normalizeProps))
|
||||
|
||||
const clients = computed(() => {
|
||||
return findByChannel(props.channel.id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="channel-list">
|
||||
<ul class="channel-list__list">
|
||||
<ChannelItem v-for="channel in channels" :key="channel.id" :channel="channel" />
|
||||
<Channel v-for="channel in channels" :key="channel.id" :channel="channel" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qChannelList } from '../api/qChannelList.ts'
|
||||
import ChannelItem from './ChannelItem.vue'
|
||||
import Channel from './Channel.vue'
|
||||
|
||||
const { data: channels } = qChannelList()
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, ResponseError } from '@shared/api/generated-chad-api.ts'
|
||||
import type { InfiniteData, QueryKey } from '@tanstack/vue-query'
|
||||
import type { QueryKey } from '@tanstack/vue-query'
|
||||
import api from '@shared/api/client.ts'
|
||||
import { useInfiniteQuery } from '@tanstack/vue-query'
|
||||
|
||||
@@ -10,10 +10,10 @@ interface Response {
|
||||
type PageParam = string | undefined
|
||||
|
||||
export function qChatMessages() {
|
||||
return useInfiniteQuery<Response, ResponseError, InfiniteData<Response, PageParam>, QueryKey, PageParam>({
|
||||
return useInfiniteQuery<Response, ResponseError, ChatMessage[], QueryKey, PageParam>({
|
||||
queryKey: ['chat-messages'],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await api.chad.chatMessages({ cursor: pageParam, limit: 30 })
|
||||
const response = await api.chad.chatMessages({ cursor: pageParam, limit: 25 })
|
||||
|
||||
return response.data
|
||||
// return {
|
||||
@@ -25,6 +25,9 @@ export function qChatMessages() {
|
||||
// pages: [...data.pages].reverse(),
|
||||
// pageParams: [...data.pageParams].reverse(),
|
||||
// }),
|
||||
select: (data) => {
|
||||
return data.pages.flatMap(page => page.messages).toReversed()
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
return lastPage.nextCursor
|
||||
|
||||
79
new-client/src/widgets/chat/composables/use-chat-scroll.ts
Normal file
79
new-client/src/widgets/chat/composables/use-chat-scroll.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { MaybeRefOrGetter, TemplateRef } from 'vue'
|
||||
import { useEventListener, useMutationObserver } from '@vueuse/core'
|
||||
import { computed, reactive, toValue, watch } from 'vue'
|
||||
|
||||
interface ChatScrollOptions {
|
||||
startOffset?: number
|
||||
endOffset?: number
|
||||
}
|
||||
|
||||
export function useChatScroll(target: TemplateRef<HTMLElement>, options?: MaybeRefOrGetter<ChatScrollOptions>) {
|
||||
const el = computed(() => toValue(target))
|
||||
|
||||
const arrivedState = reactive({
|
||||
start: true,
|
||||
end: false,
|
||||
})
|
||||
|
||||
watch(arrivedState, () => {
|
||||
console.log('arrived state', arrivedState)
|
||||
})
|
||||
|
||||
watch(el, (el) => {
|
||||
if (!el)
|
||||
return
|
||||
|
||||
scrollToStart(true)
|
||||
getArrivedState()
|
||||
}, { flush: 'post' })
|
||||
|
||||
useMutationObserver(el, () => {
|
||||
if (arrivedState.start) {
|
||||
scrollToStart(true)
|
||||
}
|
||||
|
||||
getArrivedState()
|
||||
}, {
|
||||
childList: true,
|
||||
// subtree: true,
|
||||
})
|
||||
|
||||
useEventListener(el, 'scroll', () => {
|
||||
getArrivedState()
|
||||
}, { passive: true })
|
||||
|
||||
useEventListener(el, 'scrollend', () => {
|
||||
getArrivedState()
|
||||
}, { passive: true })
|
||||
|
||||
function getArrivedState() {
|
||||
if (!el.value) {
|
||||
arrivedState.start = true
|
||||
arrivedState.end = false
|
||||
return
|
||||
}
|
||||
|
||||
const { startOffset = 0, endOffset = 0 } = toValue(options) ?? {}
|
||||
|
||||
const offsetHeight = el.value.offsetHeight
|
||||
const scrollHeight = el.value.scrollHeight
|
||||
const scrollTop = Math.abs(el.value.scrollTop)
|
||||
|
||||
arrivedState.start = scrollTop + offsetHeight >= scrollHeight - startOffset
|
||||
arrivedState.end = scrollTop <= endOffset
|
||||
}
|
||||
|
||||
function scrollToStart(instant = false) {
|
||||
if (!el.value)
|
||||
return
|
||||
|
||||
const offset = Math.ceil(el.value.scrollHeight - el.value.offsetHeight)
|
||||
|
||||
el.value.scrollTo({ top: offset, behavior: instant ? 'instant' : 'smooth' })
|
||||
}
|
||||
|
||||
return {
|
||||
arrivedState,
|
||||
scrollToStart,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
|
||||
import { useSignaling } from '@shared/composables/use-signaling'
|
||||
import { createGlobalState } from '@vueuse/core'
|
||||
import { shallowRef, triggerRef, watch } from 'vue'
|
||||
import { qChatMessages } from '../api/qChatMessages.ts'
|
||||
|
||||
export const useChat = createGlobalState(() => {
|
||||
const { data: messages, hasNextPage: hasMoreMessages, fetchNextPage, isFetching } = qChatMessages()
|
||||
|
||||
return {
|
||||
messages,
|
||||
hasMoreMessages,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
}
|
||||
const { socket, connected } = useSignaling()
|
||||
const feedItems = shallowRef<ChatMessage[]>([])
|
||||
|
||||
watch(connected, (isConnected) => {
|
||||
if (!isConnected)
|
||||
return
|
||||
|
||||
socket.value!.on('chat:new-message', (message: ChatMessage) => {
|
||||
feedItems.value.push(message)
|
||||
triggerRef(feedItems)
|
||||
})
|
||||
})
|
||||
|
||||
return { messages, feedItems, hasMoreMessages, fetchNextPage, isFetching }
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div>
|
||||
<div class="chat-message__top">
|
||||
<p class="chat-message__sender">
|
||||
{{ !sender ? message.senderId : sender.displayName }}
|
||||
{{ !sender ? message.senderUsername : sender.displayName }}
|
||||
</p>
|
||||
|
||||
<p class="chat-message__datetime">
|
||||
@@ -16,9 +16,7 @@
|
||||
{{ 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>
|
||||
<ChatMessageAttachments v-if="message.attachments.length > 0" :attachments="message.attachments" class="chat-message__attachments" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,7 +26,7 @@ import type { ChatMessage } from '@shared/api/generated-chad-api.ts'
|
||||
import ChadAvatar from '@shared/components/ui/Avatar.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 ChatMessageAttachments from '@widgets/chat/ui/ChatMessageAttachments.vue'
|
||||
import { format, isThisYear, isToday } from 'date-fns'
|
||||
import { computed, toRef } from 'vue'
|
||||
|
||||
@@ -39,12 +37,12 @@ const props = defineProps<{
|
||||
const { me } = useAuth()
|
||||
|
||||
const isMyMessage = computed(() => {
|
||||
return props.message.senderId === me.value?.id
|
||||
return props.message.senderUsername === me.value?.username
|
||||
})
|
||||
|
||||
const { user: sender, isFetching: fetchingSender } = useUserDetails(toRef(() => props.message.senderId))
|
||||
const { user: sender } = useUserDetails(toRef(() => props.message.senderUsername))
|
||||
|
||||
const initials = computed(() => props.message.senderId.slice(props.message.senderId.length - 2))
|
||||
const initials = computed(() => props.message.senderUsername.slice(props.message.senderUsername.length - 2))
|
||||
|
||||
const datetime = computed(() => {
|
||||
let formatStr = 'd MMMM yyyy, HH:mm'
|
||||
@@ -68,7 +66,8 @@ const datetime = computed(() => {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: 'avatar top' 'avatar body';
|
||||
column-gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
padding-block: var(--space-3);
|
||||
padding-inline: var(--space-6);
|
||||
|
||||
&__avatar {
|
||||
grid-area: avatar;
|
||||
@@ -99,11 +98,8 @@ const datetime = computed(() => {
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
//overflow-x: auto;
|
||||
margin-top: var(--space-2);
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,46 +1,91 @@
|
||||
<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 class="chat-message-attachment" @click="download">
|
||||
<div class="chat-message-attachment__extension">
|
||||
{{ extension }}
|
||||
</div>
|
||||
<div class="chat-message-attachment__body">
|
||||
<p class="chat-message-attachment__name">
|
||||
{{ attachment.name }}
|
||||
</p>
|
||||
<p class="chat-message-attachment__size">
|
||||
{{ attachment.size }} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qMessageAttachment } from '@widgets/chat/api/qMessageAttachment.ts'
|
||||
import { computed, toRef } from 'vue'
|
||||
import type { Attachment } from '@shared/api/generated-chad-api.ts'
|
||||
import api from '@shared/api/client.ts'
|
||||
import { downloadFile } from '@zag-js/file-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
attachmentId: string
|
||||
attachment: Attachment
|
||||
}>()
|
||||
|
||||
const { isFetching, data: attachment } = qMessageAttachment(toRef(() => props.attachmentId))
|
||||
const extension = computed(() => props.attachment.name.split('.')[1])
|
||||
|
||||
const isImage = computed(() => {
|
||||
return attachment.value?.type.startsWith('image/')
|
||||
const url = computed(() => {
|
||||
return `${__API_BASE_URL__}/attachment/${props.attachment.id}`
|
||||
})
|
||||
|
||||
const src = computed(() => {
|
||||
if (!attachment.value || !isImage.value)
|
||||
return undefined
|
||||
async function download() {
|
||||
const response = await api.chad.attachmentGet(props.attachment.id, { format: 'blob' })
|
||||
|
||||
return URL.createObjectURL(attachment.value)
|
||||
})
|
||||
downloadFile({ name: props.attachment.name, type: props.attachment.mimetype, file: response.data })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message-attachment {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr;
|
||||
outline: var(--border-w) solid var(--ink);
|
||||
outline-offset: calc(var(--border-w) * -1);
|
||||
background-color: var(--grey-1);
|
||||
width: fit-content;
|
||||
width: 300px;
|
||||
height: 52px;
|
||||
//overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
> img {
|
||||
max-width: 260px;
|
||||
max-height: 160px;
|
||||
&:hover {
|
||||
background-color: var(--grey-2);
|
||||
}
|
||||
|
||||
&__extension {
|
||||
@include font-label;
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 52px;
|
||||
text-align: center;
|
||||
background-color: var(--ink);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__name {
|
||||
@include font-body-bold;
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__size {
|
||||
@include font-micro;
|
||||
|
||||
margin-top: var(--space-1);
|
||||
color: var(--grey-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
51
new-client/src/widgets/chat/ui/ChatMessageAttachments.vue
Normal file
51
new-client/src/widgets/chat/ui/ChatMessageAttachments.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="chat-message-attachments">
|
||||
<div v-if="images.length > 0" class="chat-message-attachments__images">
|
||||
<ChatMessageImageAttachment v-for="attachment in images" :key="attachment.id" :attachment="attachment" />
|
||||
</div>
|
||||
|
||||
<div v-if="notImages.length > 0" class="chat-message-attachments__list">
|
||||
<ChatMessageAttachment v-for="attachment in notImages" :key="attachment.id" :attachment="attachment" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Attachment } from '@shared/api/generated-chad-api.ts'
|
||||
import ChatMessageAttachment from '@widgets/chat/ui/ChatMessageAttachment.vue'
|
||||
import ChatMessageImageAttachment from '@widgets/chat/ui/ChatMessageImageAttachment.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
attachments: Attachment[]
|
||||
}>()
|
||||
|
||||
const images = computed(() => {
|
||||
return props.attachments.filter(attachment => attachment.mimetype.startsWith('image/'))
|
||||
})
|
||||
|
||||
const notImages = computed(() => {
|
||||
return props.attachments.filter(attachment => !attachment.mimetype.startsWith('image/'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message-attachments {
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__images {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&__list {
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="chat-message-image-attachment">
|
||||
<img :src="src" :alt="attachment.name" draggable="false">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Attachment } from '@shared/api/generated-chad-api.ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
attachment: Attachment
|
||||
}>()
|
||||
|
||||
const src = computed(() => {
|
||||
return `${__API_BASE_URL__}/attachment/${props.attachment.id}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chat-message-image-attachment {
|
||||
outline: var(--border-w) solid var(--ink);
|
||||
outline-offset: calc(var(--border-w) * -1);
|
||||
background-color: var(--grey-1);
|
||||
width: fit-content;
|
||||
height: 160px;
|
||||
|
||||
> img {
|
||||
max-width: 260px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,64 @@
|
||||
<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>
|
||||
<ChatMessage v-for="message in messages" :key="message.id" :message="message" />
|
||||
<ChatMessage v-for="item in feedItems" :key="item.id" :message="item" />
|
||||
</div>
|
||||
|
||||
<ChadSpinner v-if="isFetching" class="chat-messages__spinner" />
|
||||
|
||||
<button v-if="!arrivedState.start" class="chat-messages__scroll-to-bottom" @click="scrollToStart()">
|
||||
<ArrowDown />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDown } from '@lucide/vue'
|
||||
import ChadSpinner from '@shared/components/ui/Spinner.vue'
|
||||
import { useInfiniteScroll } from '@vueuse/core'
|
||||
import { useEventBus } from '@shared/composables/use-event-bus.ts'
|
||||
import { useChatScroll } from '@widgets/chat/composables/use-chat-scroll.ts'
|
||||
import ChatMessage from '@widgets/chat/ui/ChatMessage.vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useChat } from '../composables/use-chat'
|
||||
|
||||
const eventBus = useEventBus()
|
||||
|
||||
const scrollRef = useTemplateRef('scroll')
|
||||
|
||||
const { messages, hasMoreMessages, fetchNextPage, isFetching } = useChat()
|
||||
const { messages, feedItems, hasMoreMessages, fetchNextPage, isFetching } = useChat()
|
||||
|
||||
useInfiniteScroll(
|
||||
scrollRef,
|
||||
() => {
|
||||
const hasUnreadMessages = ref(false)
|
||||
|
||||
const { arrivedState, scrollToStart } = useChatScroll(scrollRef, {
|
||||
startOffset: 100,
|
||||
endOffset: 100,
|
||||
})
|
||||
|
||||
watch(() => arrivedState.end, (arrived) => {
|
||||
if (!arrived)
|
||||
return
|
||||
|
||||
if (hasMoreMessages.value) {
|
||||
fetchNextPage()
|
||||
},
|
||||
{
|
||||
distance: 500,
|
||||
direction: 'top',
|
||||
interval: 300,
|
||||
canLoadMore: () => hasMoreMessages.value && !isFetching.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => arrivedState.start, () => {
|
||||
hasUnreadMessages.value = false
|
||||
})
|
||||
|
||||
eventBus.on('chat:new-message', onNewChatMessage)
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('chat:new-message', onNewChatMessage)
|
||||
})
|
||||
|
||||
function onNewChatMessage() {
|
||||
if (!arrivedState.start) {
|
||||
hasUnreadMessages.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -49,11 +75,34 @@ useInfiniteScroll(
|
||||
}
|
||||
|
||||
&__list {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&__scroll-to-bottom {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
aspect-ratio: 1;
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
outline: var(--border-w) solid var(--ink);
|
||||
outline-offset: calc(var(--border-w) * -1);
|
||||
border: none;
|
||||
background-color: var(--grey-1);
|
||||
padding: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--grey-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
63
new-client/src/widgets/control-panel/ui/ControlPanel.vue
Normal file
63
new-client/src/widgets/control-panel/ui/ControlPanel.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="control-panel">
|
||||
<ChadTag class="control-panel__status" :type="connected ? 'success' : 'error'">
|
||||
{{ connected ? 'Connected' : 'Disconnected' }}
|
||||
</ChadTag>
|
||||
|
||||
<div class="control-panel__toggles">
|
||||
<ChadToggle :model-value="isMicEnabled" @update:model-value="toggleMic">
|
||||
MIC
|
||||
</ChadToggle>
|
||||
<ChadToggle :model-value="isSoundEnabled" @update:model-value="toggleSound">
|
||||
SND
|
||||
</ChadToggle>
|
||||
<ChadToggle :model-value="isCameraEnabled" @update:model-value="toggleCamera">
|
||||
CAM
|
||||
</ChadToggle>
|
||||
<ChadToggle :model-value="isShareEnabled" @update:model-value="toggleShare">
|
||||
SHR
|
||||
</ChadToggle>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChadTag from '@shared/components/ui/Tag.vue'
|
||||
import ChadToggle from '@shared/components/ui/Toggle.vue'
|
||||
import { useMediaControls } from '@shared/composables/use-media-controls.ts'
|
||||
import { useSignaling } from '@shared/composables/use-signaling.ts'
|
||||
|
||||
const { connected } = useSignaling()
|
||||
const {
|
||||
isMicEnabled,
|
||||
isSoundEnabled,
|
||||
isCameraEnabled,
|
||||
isShareEnabled,
|
||||
toggleMic,
|
||||
toggleSound,
|
||||
toggleCamera,
|
||||
toggleShare,
|
||||
} = useMediaControls()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.control-panel {
|
||||
border-top: var(--border-w) solid var(--ink);
|
||||
padding: var(--space-3);
|
||||
background-color: var(--grey-1);
|
||||
|
||||
&__status {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__toggles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
align-items: center;
|
||||
|
||||
> *:not(:last-child) {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user