feat(chat): added new chat feature
Build and Push Docker image / build-and-push (push) Successful in 5m32s Details

you can now enable chat rooms for eventLists
This commit is contained in:
Valentin Kolb 2024-06-11 23:38:40 +02:00
parent 7cef873acd
commit 390edb38bd
31 changed files with 320 additions and 598 deletions

View File

@ -9,5 +9,5 @@ export const PB_STORAGE_KEY = "stuve-it-login-record"
// general // general
export const APP_NAME = "StuVe IT" export const APP_NAME = "StuVe IT"
export const APP_VERSION = "0.8.11 (beta)" export const APP_VERSION = "0.9.0 (beta)"
export const APP_URL = "https://it.stuve.uni-ulm.de" export const APP_URL = "https://it.stuve.uni-ulm.de"

View File

@ -9,7 +9,6 @@ import RegisterModal from "@/components/users/modals/RegisterModal.tsx";
import EmailTokenVerification from "@/components/users/modals/EmailTokenVerification.tsx"; import EmailTokenVerification from "@/components/users/modals/EmailTokenVerification.tsx";
import ForgotPasswordModal from "@/components/users/modals/ForgotPasswordModal.tsx"; import ForgotPasswordModal from "@/components/users/modals/ForgotPasswordModal.tsx";
import ChangeEmailModal from "@/components/users/modals/ChangeEmailModal.tsx"; import ChangeEmailModal from "@/components/users/modals/ChangeEmailModal.tsx";
import ShowMessagesModal from "@/components/users/modals/ShowMessagesModal";
export default function Layout({hideNav}: { hideNav?: boolean }) { export default function Layout({hideNav}: { hideNav?: boolean }) {
return <div className={classes.container}> return <div className={classes.container}>
@ -21,7 +20,6 @@ export default function Layout({hideNav}: { hideNav?: boolean }) {
<EmailTokenVerification/> <EmailTokenVerification/>
<ForgotPasswordModal/> <ForgotPasswordModal/>
<ChangeEmailModal/> <ChangeEmailModal/>
<ShowMessagesModal/>
<div className={`${classes.body}`}> <div className={`${classes.body}`}>
<div className={`${classes.content} no-scrollbar`}> <div className={`${classes.content} no-scrollbar`}>

View File

@ -1,26 +0,0 @@
import {MessagesModel} from "@/models/MessageTypes.ts";
import {Notification} from '@mantine/core';
import InnerHtml from "@/components/InnerHtml";
import {getUserName} from "@/components/users/modals/util.tsx";
export default function Message({message}: { message: MessagesModel }) {
const senderName = getUserName(message.expand?.sender)
let title
if (message.subject) {
title = `${senderName} - ${message.subject}`
} else {
title = senderName
}
return <>
<Notification title={
title
} withCloseButton={false} radius="lg">
<InnerHtml html={message.content ?? ""}/>
</Notification>
</>
}

View File

@ -1,5 +0,0 @@
.messagesContainer {
display: flex;
flex-direction: column-reverse;
gap: var(--gap);
}

View File

@ -1,109 +0,0 @@
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Modal, Stack, Text, ThemeIcon} from "@mantine/core";
import {useShowMessages} from "@/components/users/modals/hooks.ts";
import PromptLoginModal from "@/components/users/modals/PromptLoginModal.tsx";
import {useInfiniteQuery} from "@tanstack/react-query";
import {IconMessageCircleOff, IconMessageCircleUp} from "@tabler/icons-react";
import Message from "@/components/users/modals/ShowMessagesModal/Message.tsx";
import InfiniteScroll from "react-infinite-scroll-component";
export default function ShowMessagesModal() {
const {value, handler} = useShowMessages()
const {user, pb} = usePB()
const PER_PAGE = 1 // todo
const {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
...query
} = useInfiniteQuery({
queryKey: ["messages", user?.id, "infinite"],
queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, PER_PAGE, {
filter: `recipients?~'${user?.id}'&&thread=null`,
sort: "-created",
expand: "sender"
})
),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
enabled: !!user
})
if (value && !user) {
return <PromptLoginModal
onAbort={handler.close}
description={"Du musst eingeloggt sein um deine Nachrichten zu sehen."}
/>
}
return <>
<Modal
styles={{
content: {minHeight: '100%'},
}}
opened={value}
onClose={handler.close}
title={"Nachrichten"}
size={"lg"}
>
<div className={"stack"}>
<PocketBaseErrorAlert error={query.error}/>
{
query.data?.pages[0].totalItems === 0 && <Stack align={"center"}>
<ThemeIcon size={"xl"} variant={"transparent"} color={"gray"}>
<IconMessageCircleOff/>
</ThemeIcon>
<Text c={"dimmed"} size={"xs"}>
Keine Nachrichten
</Text>
</Stack>
}
<InfiniteScroll
dataLength={query.data?.pages.length ?? 0}
next={fetchNextPage}
hasMore={hasNextPage}
loader={<h4>Loading...</h4>}
endMessage={
<p style={{textAlign: 'center'}}>
<b>Yay! You have seen it all</b>
</p>
}
refreshFunction={query.refetch}
pullDownToRefresh
pullDownToRefreshThreshold={50}
pullDownToRefreshContent={
<h3 style={{textAlign: 'center'}}>&#8595; Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{textAlign: 'center'}}>&#8593; Release to refresh</h3>
}
>
<div>
{
query.data?.pages.map(page => page?.items.map((message) => (
<Message message={message} key={message.id}/>
)))
}
<Button
leftSection={<IconMessageCircleUp/>}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}
loading={isFetchingNextPage}
>
Mehr Nachrichten laden
</Button>
</div>
</InfiniteScroll>
</div>
</Modal>
</>
}

View File

@ -36,6 +36,7 @@ export type EventListModel = {
favourite: boolean | null; favourite: boolean | null;
allowOverlappingEntries: boolean | null; allowOverlappingEntries: boolean | null;
onlyStuVeAccounts: boolean | null; onlyStuVeAccounts: boolean | null;
enableChat: boolean | null;
event: string event: string
entryQuestionSchema: FormSchema | null entryQuestionSchema: FormSchema | null
entryStatusSchema: FormSchema | null; entryStatusSchema: FormSchema | null;

View File

@ -1,13 +1,15 @@
import {RecordModel} from "pocketbase"; import {RecordModel} from "pocketbase";
import {UserModal} from "@/models/AuthTypes.ts"; import {UserModal} from "@/models/AuthTypes.ts";
import {EventListModel} from "@/models/EventTypes.ts";
export type MessagesModel = { export type MessagesModel = {
sender: string sender: string
recipients: string[] recipient: string | null
thread: string | null eventList: string | null
subject: string | null subject: string | null
content: string content: string
comment: string | null
repliedTo: string | null repliedTo: string | null
@ -15,19 +17,8 @@ export type MessagesModel = {
expand: { expand: {
sender: UserModal sender: UserModal
recipients: UserModal[] recipient: UserModal
thread: MessageThreadsModel | null eventList: EventListModel | null
repliedTo: MessagesModel | null repliedTo: MessagesModel | null
} }
} & RecordModel } & RecordModel
export type MessageThreadsModel = {
name: string
participants: string[]
img: string | null
systemThread: boolean | null
expand: {
participants: UserModal[]
}
} & RecordModel

View File

@ -8,7 +8,7 @@ import {
EventListSlotsWithEntriesCountModel, EventListSlotsWithEntriesCountModel,
EventModel EventModel
} from "./EventTypes.ts"; } from "./EventTypes.ts";
import {MessagesModel, MessageThreadsModel} from "@/models/MessageTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts";
export type SettingsModel = { export type SettingsModel = {
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions'] key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
@ -29,8 +29,6 @@ export interface TypedPocketBase extends PocketBase {
collection(idOrName: 'messages'): RecordService<MessagesModel> collection(idOrName: 'messages'): RecordService<MessagesModel>
collection(idOrName: 'messageThreads'): RecordService<MessageThreadsModel>
collection(idOrName: 'settings'): RecordService<SettingsModel> collection(idOrName: 'settings'): RecordService<SettingsModel>
collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal> collection(idOrName: 'legalSettings'): RecordService<LegalSettingsModal>

View File

@ -4,8 +4,8 @@ import classes from "./ChatRouter.module.css";
import ConversationSvg from "@/illustrations/conversation.svg?react"; import ConversationSvg from "@/illustrations/conversation.svg?react";
import {useMediaQuery} from "@mantine/hooks"; import {useMediaQuery} from "@mantine/hooks";
import MessageThreadsList from "@/pages/chat/MessageThreadsList.tsx"; import EventListMessagesList from "@/pages/chat/EventListMessagesList.tsx";
import MessageThreadView from "@/pages/chat/MessageThreadView.tsx"; import ListMessagesView from "@/pages/chat/ListMessagesView.tsx";
import {usePB} from "@/lib/pocketbase.tsx"; import {usePB} from "@/lib/pocketbase.tsx";
import {useLogin} from "@/components/users/modals/hooks.ts"; import {useLogin} from "@/components/users/modals/hooks.ts";
import Announcements from "@/pages/chat/components/Announcements.tsx"; import Announcements from "@/pages/chat/components/Announcements.tsx";
@ -34,7 +34,7 @@ export default function ChatRouter() {
{title: "Home", to: "/"}, {title: "Home", to: "/"},
{title: "Nachrichten", to: "/chat"}, {title: "Nachrichten", to: "/chat"},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>
{title} {title}
</Anchor> </Anchor>
))}</Breadcrumbs> ))}</Breadcrumbs>
@ -44,12 +44,12 @@ export default function ChatRouter() {
<Outlet/> <Outlet/>
<Routes> <Routes>
<Route path={isMobile ? "/" : "*"} element={<MessageThreadsList/>}/> <Route path={isMobile ? "/" : "*"} element={<EventListMessagesList/>}/>
</Routes> </Routes>
<Routes> <Routes>
{!isMobile && <Route index element={<ChatIndex/>}/>} {!isMobile && <Route index element={<ChatIndex/>}/>}
<Route path={":threadId"} element={<MessageThreadView/>}/> <Route path={":listId"} element={<ListMessagesView/>}/>
<Route path={"announcements"} element={<Announcements/>}/> <Route path={"announcements"} element={<Announcements/>}/>
</Routes> </Routes>
</div> </div>

View File

@ -14,7 +14,7 @@
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
} }
.threadText { .listText {
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
width: calc(100% - 40px); width: calc(100% - 40px);
@ -28,7 +28,7 @@
} }
} }
.threadLink { .listLink {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -36,7 +36,7 @@
gap: var(--gap); gap: var(--gap);
} }
.threadsContainer { .listsContainer {
//flex-grow: 1; //flex-grow: 1;
overflow: auto; overflow: auto;
border: var(--border); border: var(--border);

View File

@ -0,0 +1,107 @@
import {Button, Center, Divider, Loader, Stack, Text, TextInput, ThemeIcon, UnstyledButton} from "@mantine/core";
import {NavLink} from "react-router-dom";
import classes from "@/pages/chat/EventListMessagesList.module.css";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconArrowDown, IconListSearch, IconMoodPuzzled, IconSpeakerphone} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {useDebouncedState} from "@mantine/hooks";
import {useInfiniteQuery} from "@tanstack/react-query";
import {EventListModel} from "@/models/EventTypes.ts";
const ListMessagesLink = ({eventList}: { eventList: EventListModel }) => {
return <UnstyledButton component={NavLink} to={`/chat/${eventList.id}`} className={classes.listLink}>
{
({isActive}) => <>
<PBAvatar model={eventList} name={eventList.name} img={eventList.img} size={"sm"}/>
<div className={classes.listText} data-active={isActive}>
{eventList.name} ({eventList.expand?.event.name})
</div>
</>
}
</UnstyledButton>
}
const AnnouncementsLink = () => {
return <>
<UnstyledButton component={NavLink} to={`/chat/announcements`} className={classes.announcementLink}>
{
({isActive}) => <>
<ThemeIcon variant="light" radius={"xl"} size="md" color="green">
<IconSpeakerphone/>
</ThemeIcon>
<div className={`${classes.listText} `} data-active={isActive}>
Ankündigungen
</div>
</>
}
</UnstyledButton>
</>
}
export default function EventListMessagesList() {
const {user, pb} = usePB()
const [eventListNameQuery, setEventListNameQuery] = useDebouncedState("", 200)
const query = useInfiniteQuery({
queryKey: ["eventLists", eventListNameQuery],
queryFn: async ({pageParam}) => (
await pb.collection("eventLists").getList(pageParam, 100, {
filter: `enableChat=true&&name~'${eventListNameQuery}'`,
sort: "-messages_via_eventList.created,name",
expand: "event"
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const eventLists = query.data?.pages.flatMap(t => t.items) || []
return <div className={`stack ${classes.container}`}>
<AnnouncementsLink/>
<Divider label={"Chats"}/>
<TextInput
leftSection={<IconListSearch/>}
rightSection={query.isPending ? <Loader size={"xs"}/> : undefined}
defaultValue={eventListNameQuery}
onChange={(e) => setEventListNameQuery(e.currentTarget.value)}
placeholder={"Nach Listen suchen..."}
/>
{eventLists.length === 0 ? <Stack gap={"xs"} align={"center"}>
<ThemeIcon variant="transparent" size="xs" color="gray">
<IconMoodPuzzled/>
</ThemeIcon>
<Text size={"xs"} c={"dimmed"}>
{eventListNameQuery ? "Keine Listen gefunden" : "Keine Listen"}
</Text>
</Stack> : (
<div className={`${classes.listsContainer} no-scrollbar`}>
{eventLists.map(list => <ListMessagesLink key={list.id} eventList={list}/>)}
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
)}
</div>
}

View File

@ -0,0 +1,92 @@
import {Link, useParams} from "react-router-dom";
import {useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {ActionIcon, Button, Center, Collapse, Group, Loader, Text} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconArrowRight, IconChevronLeft, IconInfoCircle, IconInfoCircleFilled} from "@tabler/icons-react";
import {useDisclosure} from "@mantine/hooks";
import Messages from "@/pages/chat/components/Messages.tsx";
import classes from './ListMessagesView.module.css';
import InnerHtml from "@/components/InnerHtml";
import {useEventRights} from "@/pages/events/util.ts";
export default function ListMessagesView() {
const {listId} = useParams() as { listId: string }
const {pb} = usePB()
const [showListInfo, showListInfoHandler] = useDisclosure(false)
const query = useQuery({
queryKey: ["eventListMessageView", listId],
queryFn: async () => (
await pb.collection("eventLists").getOne(listId, {expand: "event"})
)
})
const {canEditEvent} = useEventRights(query.data?.expand?.event)
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
const eventList = query.data
return <div className={"stack"}>
<Group wrap={"nowrap"}>
<ActionIcon
component={Link}
to={"/chat"}
variant={"transparent"} color={"blue"}
radius={"xl"}
aria-label={"Back to Threads"}
className={classes.backIcon}
>
<IconChevronLeft/>
</ActionIcon>
<PBAvatar model={eventList} name={eventList.name} img={eventList.img} size={"lg"}/>
<div>
<Text size={"lg"} fw={600}>{eventList.name}</Text>
<Text size={"xs"} c={"dimmed"}>{eventList.expand?.event.name}</Text>
</div>
<ActionIcon
variant={"transparent"} color={"blue"}
radius={"xl"} ms={"auto"}
aria-label={"Thread Info"}
onClick={showListInfoHandler.toggle}
>
{showListInfo ? <IconInfoCircleFilled/> : <IconInfoCircle/>}
</ActionIcon>
</Group>
<Collapse in={showListInfo}>
<div className={"stack"}>
<InnerHtml html={eventList.description ?? "Keine Beschreibung vorhanden"}/>
{
(canEditEvent || eventList.expand?.event.privilegedLists.includes(eventList.id))
&&
<Group>
<Button
rightSection={<IconArrowRight/>}
variant={"light"} color={"blue"}
size={"xs"}
component={Link}
to={`/events/e/${eventList.event}/lists/overview/${eventList.id}`}
>
Zur Liste
</Button>
</Group>
}
</div>
</Collapse>
<Messages eventList={eventList}/>
</div>
}

View File

@ -1,134 +0,0 @@
import {Link, useNavigate, useParams} from "react-router-dom";
import {useMutation, useQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {ActionIcon, Alert, Button, Center, Collapse, Group, Loader, Text} from "@mantine/core";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconChevronLeft, IconEdit, IconInfoCircle, IconTrash} from "@tabler/icons-react";
import {useDisclosure} from "@mantine/hooks";
import UsersDisplay from "@/components/users/UsersDisplay.tsx";
import UpsertThreadForm from "@/pages/chat/components/UpsertThreadForm.tsx";
import Messages from "@/pages/chat/components/Messages.tsx";
import {useConfirmModal} from "@/components/ConfirmModal.tsx";
import classes from './MessageThreadView.module.css';
export default function MessageThreadView() {
const {threadId} = useParams() as { threadId: string }
const {pb} = usePB()
const [showEditThread, showEditThreadHandler] = useDisclosure(false)
const [showThreadInfo, showThreadInfoHandler] = useDisclosure(false)
const query = useQuery({
queryKey: ["messageThreads", threadId],
queryFn: async () => (
await pb.collection("messageThreads").getOne(threadId, {
expand: "participants"
})
)
})
const navigate = useNavigate()
const deleteThreadMutation = useMutation({
mutationFn: async () => {
await pb.collection("messageThreads").delete(threadId)
},
onSuccess: () => {
navigate("/chat")
}
})
const {toggleConfirmModal, ConfirmModal} = useConfirmModal({
title: "Thread löschen",
description: "Bist du sicher, dass du diesen Thread löschen möchtest?",
onConfirm: () => deleteThreadMutation.mutate()
})
if (query.isError) return (
<PocketBaseErrorAlert error={query.error}/>
)
if (query.isLoading || !query.data) return (
<Center>
<Loader/>
</Center>
)
const thread = query.data
return <div className={"stack"}>
<ConfirmModal/>
<Group wrap={"nowrap"}>
<ActionIcon
component={Link}
to={"/chat"}
variant={"transparent"} color={"blue"}
radius={"xl"}
aria-label={"Back to Threads"}
className={classes.backIcon}
>
<IconChevronLeft/>
</ActionIcon>
<PBAvatar model={thread} name={thread.name} img={thread.img} size={"lg"}/>
<Text size={"lg"} fw={600}>{thread.name}</Text>
<ActionIcon
variant={"transparent"} color={"blue"}
radius={"xl"} ms={"auto"}
aria-label={"Thread Info"}
onClick={showThreadInfoHandler.toggle}
>
<IconInfoCircle/>
</ActionIcon>
</Group>
<Collapse in={showThreadInfo}>
<Alert color={"blue"} title={"Thread Info"}>
<div className={"stack"}>
{
thread.expand.participants && (
<UsersDisplay users={thread.expand.participants}/>
)
}
{!showEditThread && <Group>
<ActionIcon
variant={"transparent"} color={"red"}
radius={"xl"}
aria-label={"Delete Thread"}
onClick={toggleConfirmModal}
>
<IconTrash/>
</ActionIcon>
<Button
variant={"light"}
color={"blue"}
size={"xs"}
radius={"xl"}
aria-label={"Edit Thread"}
onClick={showEditThreadHandler.toggle}
leftSection={<IconEdit/>}
>
Bearbeiten
</Button>
</Group>
}
<Collapse in={showEditThread}>
<UpsertThreadForm thread={thread} onSuccess={() => {
showEditThreadHandler.toggle()
query.refetch()
}} onCancel={showEditThreadHandler.toggle}/>
</Collapse>
</div>
</Alert>
</Collapse>
<Messages thread={thread}/>
</div>
}

View File

@ -1,140 +0,0 @@
import {MessageThreadsModel} from "@/models/MessageTypes.ts";
import {
ActionIcon,
Alert,
Button,
Center,
Collapse,
Divider,
Loader,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton
} from "@mantine/core";
import {NavLink} from "react-router-dom";
import classes from "@/pages/chat/MessageThreadsList.module.css";
import PBAvatar from "@/components/PBAvatar.tsx";
import {IconArrowDown, IconMinus, IconNeedleThread, IconPlus, IconSpeakerphone} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {useDebouncedState, useDisclosure} from "@mantine/hooks";
import {useInfiniteQuery} from "@tanstack/react-query";
import UpsertThreadForm from "@/pages/chat/components/UpsertThreadForm.tsx";
const MessageThreadLink = ({thread}: { thread: MessageThreadsModel }) => {
return <UnstyledButton component={NavLink} to={`/chat/${thread.id}`} className={classes.threadLink}>
{
({isActive}) => <>
<PBAvatar model={thread} name={thread.name} img={thread.img} size={"sm"}/>
<div className={classes.threadText} data-active={isActive}>
{thread.name}
</div>
</>
}
</UnstyledButton>
}
const AnnouncementsLink = () => {
return <>
<UnstyledButton component={NavLink} to={`/chat/announcements`} className={classes.announcementLink}>
{
({isActive}) => <>
<ThemeIcon variant="light" radius={"xl"} size="md" color="green">
<IconSpeakerphone/>
</ThemeIcon>
<div className={`${classes.threadText} `} data-active={isActive}>
Ankündigungen
</div>
</>
}
</UnstyledButton>
</>
}
export default function MessageThreadsList() {
const {user, pb} = usePB()
const [showCreateThread, showCreateThreadHandler] = useDisclosure(false)
const [threadSearchQuery, setThreadSearchQuery] = useDebouncedState("", 200)
const query = useInfiniteQuery({
queryKey: ["threads", threadSearchQuery],
queryFn: async ({pageParam}) => (
await pb.collection("messageThreads").getList(pageParam, 100, {
filter: `participants ?~ '${user?.id}' && name ~ '${threadSearchQuery}' && systemThread != true`,
})
),
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
enabled: !!user,
})
const threads = query.data?.pages.flatMap(t => t.items) || []
return <div className={`stack ${classes.container}`}>
<AnnouncementsLink/>
<Divider label={"Threads"}/>
<TextInput
leftSection={<IconNeedleThread/>}
rightSection={query.isPending ? <Loader size={"xs"}/> : (
<ActionIcon
onClick={() => showCreateThreadHandler.toggle()}
color={"green"}
variant={"transparent"}
>
{showCreateThread ? <IconMinus/> : <IconPlus/>}
</ActionIcon>
)}
defaultValue={threadSearchQuery}
onChange={(e) => setThreadSearchQuery(e.currentTarget.value)}
placeholder={"Nach Threads suchen..."}
/>
<Collapse in={showCreateThread}>
<Alert className={"stack"} color={"green"} title={"Neuen Thread erstellen"}>
<UpsertThreadForm onSuccess={() => {
query.refetch()
showCreateThreadHandler.close()
}} onCancel={showCreateThreadHandler.close}/>
</Alert>
</Collapse>
{threads.length === 0 ? <Stack gap={"xs"} align={"center"}>
<ThemeIcon variant="transparent" size="xs" color="gray">
<IconNeedleThread/>
</ThemeIcon>
<Text size={"xs"} c={"dimmed"}>
{threadSearchQuery ? "Keine Threads gefunden" : "Keine Threads"}
</Text>
</Stack> : (
<div className={`${classes.threadsContainer} no-scrollbar`}>
{threads.map(thread => <MessageThreadLink key={thread.id} thread={thread}/>)}
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
)}
</div>
}

View File

@ -8,7 +8,6 @@ export default function Announcement({subject, content}: {
content: string, content: string,
}) { }) {
return <div className={classes.announcement}> return <div className={classes.announcement}>
<Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}> <Group justify={"space-between"} wrap={"nowrap"} align={"top"} mb={"md"}>
{subject && <div className={classes.subject}> {subject && <div className={classes.subject}>
{subject} {subject}

View File

@ -1,7 +1,6 @@
import {useInfiniteQuery} from "@tanstack/react-query"; import {useInfiniteQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Center, Loader, Text} from "@mantine/core"; import {Button, Center, Loader, Text} from "@mantine/core";
import classes from './Announcements.module.css' import classes from './Announcements.module.css'
import {IconMessageCircleUp} from "@tabler/icons-react"; import {IconMessageCircleUp} from "@tabler/icons-react";
import Announcement from "@/pages/chat/components/Announcement.tsx"; import Announcement from "@/pages/chat/components/Announcement.tsx";
@ -13,7 +12,7 @@ export default function Announcements() {
queryKey: ["announcements"], queryKey: ["announcements"],
queryFn: async ({pageParam}) => ( queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, 100, { await pb.collection("messages").getList(pageParam, 100, {
filter: `isAnnouncement=true`, filter: `isAnnouncement=true&&sender!='${user?.id}'`,
sort: "-created" sort: "-created"
}) })
), ),

View File

@ -11,28 +11,28 @@ export default function ChatNavIcon() {
const [newMessage, setNewMessage] = useState<string | null>(null); const [newMessage, setNewMessage] = useState<string | null>(null);
const {start} = useTimeout(() => setNewMessage(null), 5000); const {start} = useTimeout(() => setNewMessage(null), 5000);
const {user, pb, useSubscription} = usePB() const {user, useSubscription} = usePB()
const match = useMatch("/chat/:threadId") const match = useMatch("/chat/:listId")
useSubscription<MessagesModel>({ useSubscription<MessagesModel>({
idOrName: "messages", idOrName: "messages",
topic: "*", topic: "*",
callback: (event) => { callback: (event) => {
if (event.action == "create" && event.record.thread) { if (event.action == "create") {
pb.collection("messageThreads").getOne(event.record.thread).then(thread => { if (event.record.isAnnouncement) {
if (thread.systemThread) { start()
start() setNewMessage("announcements")
setNewMessage("announcements") } else if (
} else if ( event.record.eventList
match?.params.threadId !== event.record.thread // check if thread is not already open &&
&& match?.params.listId !== event.record.eventList // check if chat page is not already open
event.record.sender !== user?.id // check if sender is not the user &&
) { event.record.sender !== user?.id // check if sender is not the user
start() ) {
setNewMessage(event.record.thread) start()
} setNewMessage(event.record.eventList)
}) }
} }
} }
}) })

View File

@ -1,26 +1,26 @@
import {MessagesModel, MessageThreadsModel} from "@/models/MessageTypes.ts"; import {MessagesModel} from "@/models/MessageTypes.ts";
import TextEditor from "@/components/input/Editor"; import TextEditor from "@/components/input/Editor";
import {useForm} from "@mantine/form"; import {useForm} from "@mantine/form";
import {ActionIcon, Button, Center, Group, Text} from "@mantine/core"; import {ActionIcon, Button, Center, Group, Text} from "@mantine/core";
import {IconMessageCircleUp, IconSend} from "@tabler/icons-react"; import {IconMessageCircleUp, IconSend} from "@tabler/icons-react";
import classes from './Messages.module.css' import classes from './Messages.module.css'
import {useInfiniteQuery, useMutation} from "@tanstack/react-query"; import {useInfiniteQuery, useMutation} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx"; import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import InnerHtml from "@/components/InnerHtml"; import InnerHtml from "@/components/InnerHtml";
import {getUserName} from "@/components/users/modals/util.tsx"; import {getUserName} from "@/components/users/modals/util.tsx";
import {pprintDateTime} from "@/lib/datetime.ts"; import {pprintDateTime} from "@/lib/datetime.ts";
import {EventListModel} from "@/models/EventTypes.ts";
export default function Messages({thread}: { export default function Messages({eventList}: {
thread: MessageThreadsModel eventList: EventListModel
}) { }) {
const {user, pb, useSubscription} = usePB() const {user, pb, useSubscription} = usePB()
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["messages", thread], queryKey: ["messages", eventList],
queryFn: async ({pageParam}) => ( queryFn: async ({pageParam}) => (
await pb.collection("messages").getList(pageParam, 100, { await pb.collection("messages").getList(pageParam, 100, {
filter: `thread='${thread?.id}'`, filter: `eventList='${eventList?.id}'`,
sort: "-created", sort: "-created",
expand: "sender" expand: "sender"
}) })
@ -35,9 +35,8 @@ export default function Messages({thread}: {
idOrName: "messages", idOrName: "messages",
topic: "*", topic: "*",
callback: (event) => { callback: (event) => {
if (event.action == "create" && event.record.thread == thread.id) { if (event.action == "create" && event.record.eventList == eventList.id) {
query.refetch() query.refetch()
console.log("test")
} }
} }
}) })
@ -47,7 +46,7 @@ export default function Messages({thread}: {
await pb.collection("messages").create({ await pb.collection("messages").create({
...formValues.values, ...formValues.values,
sender: user!.id, sender: user!.id,
thread: thread.id, eventList: eventList.id,
}) })
}, },
onSuccess: () => { onSuccess: () => {

View File

@ -1,76 +0,0 @@
import {MessageThreadsModel} from "@/models/MessageTypes.ts";
import {usePB} from "@/lib/pocketbase.tsx";
import {useForm} from "@mantine/form";
import {useMutation} from "@tanstack/react-query";
import {ActionIcon, Box, Button, Group, TextInput} from "@mantine/core";
import UserInput from "@/components/users/UserInput.tsx";
import {IconX} from "@tabler/icons-react";
export default function UpsertThreadForm({thread, onSuccess, onCancel}: {
thread?: MessageThreadsModel,
onSuccess: () => void,
onCancel: () => void
}) {
const {user, pb} = usePB()
const formValues = useForm({
initialValues: {
name: thread?.name || "",
description: thread?.description || "",
participants: thread?.expand.participants || [user!],
},
validate: {
name: (value) => {
if (value.length < 1) {
return "Bitte gib einen Namen ein"
}
},
}
})
const mutation = useMutation({
mutationFn: async () => {
const values = {
name: formValues.values.name,
description: formValues.values.description,
participants: formValues.values.participants.map(p => p.id),
}
if (thread) {
await pb.collection("messageThreads").update(thread.id, values)
} else {
await pb.collection("messageThreads").create(values)
}
},
onSuccess: () => {
onSuccess()
}
})
return <form onSubmit={formValues.onSubmit(() => mutation.mutate())}>
<TextInput
mb={"sm"}
placeholder={"Name"}
{...formValues.getInputProps("name")}
/>
<Box mb={"sm"}>
<UserInput
placeholder={"Teilnehmer"}
selectedRecords={formValues.values.participants}
setSelectedRecords={(value) => formValues.setFieldValue("participants", value)}
/>
</Box>
<Group>
<ActionIcon variant={"transparent"} size={"sm"} aria-label={"abort"} onClick={onCancel}>
<IconX/>
</ActionIcon>
<Button
size={"xs"}
variant={"light"}
type={"submit"}
loading={mutation.isPending}
>
Speichern
</Button>
</Group>
</form>
}

View File

@ -19,7 +19,7 @@ export default function EventOverview() {
<Breadcrumbs>{ <Breadcrumbs>{
[{title: "Home", to: "/"}, {title: "Events", to: "/events"}, [{title: "Home", to: "/"}, {title: "Events", to: "/events"},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}>{title}</Anchor> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>{title}</Anchor>
)) ))
}</Breadcrumbs> }</Breadcrumbs>
</div> </div>

View File

@ -1,6 +1,6 @@
import QRCodeModal from "@/pages/events/StatusEditor/QrCodeModal.tsx"; import QRCodeModal from "@/pages/events/StatusEditor/QrCodeModal.tsx";
import EntrySearchModal from "@/pages/events/StatusEditor/EntrySearchModal.tsx"; import EntrySearchModal from "@/pages/events/StatusEditor/EntrySearchModal.tsx";
import {ActionIcon, Center, Group, Text} from "@mantine/core"; import {ActionIcon, Group, Text, Title} from "@mantine/core";
import {IconQrcode, IconUserSearch} from "@tabler/icons-react"; import {IconQrcode, IconUserSearch} from "@tabler/icons-react";
import {useDisclosure} from "@mantine/hooks"; import {useDisclosure} from "@mantine/hooks";
import SearchSVG from "@/illustrations/search.svg?react" import SearchSVG from "@/illustrations/search.svg?react"
@ -14,11 +14,13 @@ export default function EntrySearch() {
<QRCodeModal opened={showQrCodeModal} onClose={showQrCodeModalHandler.close}/> <QRCodeModal opened={showQrCodeModal} onClose={showQrCodeModalHandler.close}/>
<EntrySearchModal opened={showEntrySearchModal} onClose={showEntrySearchModalHandler.close}/> <EntrySearchModal opened={showEntrySearchModal} onClose={showEntrySearchModalHandler.close}/>
<Center> <div className={"stack center"}>
<SearchSVG height={"300px"} width={"300px"}/> <SearchSVG height={"300px"} width={"300px"}/>
</Center>
<div className={" center"}> <Title c={"blue"} fw={900} mb={"xl"}>
Status Editor
</Title>
<Group justify={"center"}> <Group justify={"center"}>
<ActionIcon <ActionIcon
variant="light" size="xl" radius="xl" variant="light" size="xl" radius="xl"

View File

@ -43,7 +43,7 @@ export default function StatusEditor() {
{title: event.name, to: `/events/${event.id}`}, {title: event.name, to: `/events/${event.id}`},
{title: "Status Editor", to: `/events/e/${event.id}/lists/status`}, {title: "Status Editor", to: `/events/e/${event.id}/lists/status`},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>
{title} {title}
</Anchor> </Anchor>
))}</Breadcrumbs> ))}</Breadcrumbs>

View File

@ -118,7 +118,7 @@ export default function EditEventRouter() {
{title: "Events", to: "/events"}, {title: "Events", to: "/events"},
{title: event.name, to: `/events/${event.id}`}, {title: event.name, to: `/events/${event.id}`},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}> <Anchor className={"wrapWords"} component={Link} to={to} key={title}>
{title} {title}
</Anchor> </Anchor>
))}</Breadcrumbs> ))}</Breadcrumbs>

View File

@ -6,6 +6,7 @@ import {
IconForms, IconForms,
IconLock, IconLock,
IconLockOpen, IconLockOpen,
IconMessageCircle,
IconSettings, IconSettings,
IconUserCog IconUserCog
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@ -57,6 +58,14 @@ export default function EventListRouter({event}: { event: EventModel }) {
} }
] ]
if (list.enableChat) {
nav.push({
icon: <IconMessageCircle/>,
to: `/chat/${list.id}`,
title: "Chat"
})
}
if (canEditEvent) { if (canEditEvent) {
nav.push(...[ nav.push(...[
{ {
@ -81,10 +90,10 @@ export default function EventListRouter({event}: { event: EventModel }) {
<Breadcrumbs> <Breadcrumbs>
<Link to={`/events/e/${event.id}/lists/overview`}> <Link to={`/events/e/${event.id}/lists/overview`}>
<Title order={2}> <Title order={2}>
Event Listen Listen
</Title> </Title>
</Link> </Link>
<Link to={`/events/e/${event.id}/lists/overview/${list.id}`}> <Link to={`/events/e/${event.id}/lists/overview/${list.id}`} className={"wrapWords"}>
<Title order={2}> <Title order={2}>
{list.name} {list.name}
</Title> </Title>

View File

@ -21,6 +21,7 @@ export default function ListSettings({list, event}: { list: EventListModel, even
open: list.open, open: list.open,
allowOverlappingEntries: list.allowOverlappingEntries, allowOverlappingEntries: list.allowOverlappingEntries,
onlyStuVeAccounts: list.onlyStuVeAccounts, onlyStuVeAccounts: list.onlyStuVeAccounts,
enableChat: list.enableChat,
favorite: list.favorite, favorite: list.favorite,
description: list.description || "", description: list.description || "",
} }
@ -105,6 +106,19 @@ export default function ListSettings({list, event}: { list: EventListModel, even
{...formValues.getInputProps("allowOverlappingEntries", {type: "checkbox"})} {...formValues.getInputProps("allowOverlappingEntries", {type: "checkbox"})}
/> />
<CheckboxCard
label={"Chat aktivieren"}
description={
"Du kannst eine neue Chatgruppe für diese Liste erstellen. " +
"Alle Anmeldungen der Liste können diesen Chat nutzen. " +
"Eventadmins können den Chat auch nutzen und alle Nachrichten bearbeiten sowie löschen. " +
"Neue Anmeldungen sehen auch vergangene Nachrichten.<br/>" +
"Der Chat wird automatisch gelöscht, wenn diese Liste gelöscht wird. Wenn du den Chat deaktivierst, " +
"werden die Nachrichten nicht gelöscht und kann der Chat kann später wieder aktiviert werden."
}
{...formValues.getInputProps("enableChat", {type: "checkbox"})}
/>
<TextEditor <TextEditor
maxHeight={"500px"} maxHeight={"500px"}
fullToolbar fullToolbar
@ -113,7 +127,6 @@ export default function ListSettings({list, event}: { list: EventListModel, even
/> />
<Group> <Group>
<Button <Button
variant={"outline"} variant={"outline"}
color={"red"} color={"red"}

View File

@ -5,7 +5,6 @@ import {
ActionIcon, ActionIcon,
Alert, Alert,
Autocomplete, Autocomplete,
Box,
Button, Button,
Center, Center,
Collapse, Collapse,
@ -22,9 +21,9 @@ import {
import {hasLength, useForm} from "@mantine/form"; import {hasLength, useForm} from "@mantine/form";
import TextEditor from "@/components/input/Editor"; import TextEditor from "@/components/input/Editor";
import {Link, useNavigate} from "react-router-dom"; import {Link, useNavigate} from "react-router-dom";
import {IconArrowRight, IconListSearch, IconLock, IconLockOpen, IconPlus} from "@tabler/icons-react"; import {IconArrowRight, IconListSearch, IconLock, IconLockOpen, IconMinus, IconPlus} from "@tabler/icons-react";
import {useRef, useState} from "react"; import {useState} from "react";
import {useDebouncedState} from "@mantine/hooks"; import {useDebouncedState, useDisclosure} from "@mantine/hooks";
import {onlyUnique} from "@/lib/util.ts"; import {onlyUnique} from "@/lib/util.ts";
import {useEventRights} from "@/pages/events/util.ts"; import {useEventRights} from "@/pages/events/util.ts";
@ -88,6 +87,8 @@ export default function EventListsOverview({event}: { event: EventModel }) {
const navigate = useNavigate() const navigate = useNavigate()
const [createNewList, createNewListHandler] = useDisclosure(false)
const formValues = useForm({ const formValues = useForm({
initialValues: { initialValues: {
name: "", name: "",
@ -123,48 +124,43 @@ export default function EventListsOverview({event}: { event: EventModel }) {
} }
}) })
const newListNameRef = useRef<HTMLInputElement>(null)
const {canEditEvent} = useEventRights(event) const {canEditEvent} = useEventRights(event)
return ( return (
<div className={"stack"}> <div className={"stack"}>
{canEditEvent && {canEditEvent &&
<form <Collapse in={createNewList}>
className={"section stack"} <form
onSubmit={formValues.onSubmit(() => createListMutation.mutate())} className={"section stack"}
> onSubmit={formValues.onSubmit(() => createListMutation.mutate())}
<Title order={3} c={"blue"}> >
<Group> <Title order={3} c={"blue"}>
<ActionIcon
mb={"5"} variant={"transparent"}
onClick={() => newListNameRef.current?.focus()}
>
<IconPlus/>
</ActionIcon>
Neue Liste Erstellen Neue Liste Erstellen
</Group> </Title>
</Title>
<PocketBaseErrorAlert error={createListMutation.error}/> <PocketBaseErrorAlert error={createListMutation.error}/>
<TextInput <TextInput
ref={newListNameRef} placeholder={"Name der neuen Liste ..."}
variant={"filled"} {...formValues.getInputProps("name")}
placeholder={"Name der neuen Liste ..."} />
{...formValues.getInputProps("name")}
/>
<Collapse in={!!formValues.values.name}>
<Title order={4} c={"blue"}>Beschreibung</Title> <Title order={4} c={"blue"}>Beschreibung</Title>
<TextEditor <TextEditor
mt={"sm"}
value={formValues.values.description} value={formValues.values.description}
onChange={(value) => formValues.setFieldValue("description", value)} onChange={(value) => formValues.setFieldValue("description", value)}
/> />
<Box mt={"sm"}> <Group>
<Button
variant={"light"}
color={"orange"}
onClick={createNewListHandler.close}
>
Abbrechen
</Button>
<Button <Button
type={"submit"} type={"submit"}
loading={createListMutation.isPending} loading={createListMutation.isPending}
@ -172,15 +168,25 @@ export default function EventListsOverview({event}: { event: EventModel }) {
> >
Liste Erstellen Liste Erstellen
</Button> </Button>
</Box> </Group>
</Collapse> </form>
</form> </Collapse>
} }
<Autocomplete <Autocomplete
placeholder={"Nach einer Liste suchen ..."} placeholder={"Nach einer Liste suchen ..."}
leftSection={<IconListSearch/>} leftSection={<IconListSearch/>}
rightSection={listsQuery.isLoading ? <Loader size={"xs"}/> : undefined} rightSection={listsQuery.isLoading ? <Loader size={"xs"}/> : (
canEditEvent &&
<Tooltip label={"Neue Liste erstellen"} withArrow>
<ActionIcon
variant={"transparent"}
onClick={createNewListHandler.toggle}
>
{createNewList ? <IconMinus/> : <IconPlus/>}
</ActionIcon>
</Tooltip>
)}
data={ data={
listsQuery listsQuery
.data .data

View File

@ -67,7 +67,7 @@ export default function UserEntries() {
{title: "Events", to: "/events"}, {title: "Events", to: "/events"},
{title: "Anmeldungen", to: `/events/entries`}, {title: "Anmeldungen", to: `/events/entries`},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>
{title} {title}
</Anchor> </Anchor>
))}</Breadcrumbs> ))}</Breadcrumbs>

View File

@ -23,7 +23,7 @@ export default function EventListView({event, listId}: { event: EventModel, list
const listSlotsQuery = useQuery({ const listSlotsQuery = useQuery({
queryKey: ["event", event.id, "list", listId, "slots", activePage], queryKey: ["event", event.id, "list", listId, "slots", activePage],
queryFn: async () => (await pb.collection("eventListSlotsWithEntriesCount").getList(activePage, 10, { queryFn: async () => (await pb.collection("eventListSlotsWithEntriesCount").getList(activePage, 20, {
filter: `eventList='${listId}'`, filter: `eventList='${listId}'`,
sort: "startDate", sort: "startDate",
enabled: !!listId, enabled: !!listId,

View File

@ -66,7 +66,7 @@ export default function SharedEvent() {
{title: "Events", to: "/events"}, {title: "Events", to: "/events"},
{title: event.name, to: `/events/${event.id}`}, {title: event.name, to: `/events/${event.id}`},
].map(({title, to}) => ( ].map(({title, to}) => (
<Anchor component={Link} to={to} key={title}> <Anchor component={Link} to={to} key={title} className={"wrapWords"}>
{title} {title}
</Anchor> </Anchor>
))}</Breadcrumbs> ))}</Breadcrumbs>

View File

@ -67,12 +67,10 @@
a { a {
text-decoration: unset; /* Removes default underline */ text-decoration: unset; /* Removes default underline */
color: revert;
} }
a:hover, a:active, a:visited { a:hover, a:active, a:visited {
text-decoration: unset; /* Removes default underline */ text-decoration: unset; /* Removes default underline */
color: unset;
} }
.section-icon { .section-icon {
@ -156,5 +154,5 @@ a:hover, a:active, a:visited {
} }
.monospace { .monospace {
font-family: var(--mantine-font-family-monospace); font-family: var(--mantine-font-family-monospace), monospace;
} }