From 41f7f26bc566de958efcde4054eb35ce0d0cacf3 Mon Sep 17 00:00:00 2001 From: bkis Date: Tue, 19 Mar 2024 16:20:18 +0100 Subject: [PATCH 01/14] Draft messaging data model and endpoints --- Tekst-API/tekst/db.py | 2 + Tekst-API/tekst/errors.py | 6 + Tekst-API/tekst/models/message.py | 77 +++++++++++++ Tekst-API/tekst/models/user.py | 2 +- Tekst-API/tekst/openapi/tags_metadata.py | 8 ++ Tekst-API/tekst/routers/messages.py | 140 +++++++++++++++++++++++ 6 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 Tekst-API/tekst/models/message.py create mode 100644 Tekst-API/tekst/routers/messages.py diff --git a/Tekst-API/tekst/db.py b/Tekst-API/tekst/db.py index 7e64de0d..2485ba2e 100644 --- a/Tekst-API/tekst/db.py +++ b/Tekst-API/tekst/db.py @@ -14,6 +14,7 @@ from tekst.models.settings import PlatformSettingsDocument from tekst.models.text import TextDocument from tekst.models.user import UserDocument +from tekst.models.message import MessageDocument from tekst.resources import resource_types_mgr @@ -48,6 +49,7 @@ async def init_odm(db: Database = get_db()) -> None: PlatformSettingsDocument, ClientSegmentDocument, UserDocument, + MessageDocument, BookmarkDocument, AccessToken, LocksStatus, diff --git a/Tekst-API/tekst/errors.py b/Tekst-API/tekst/errors.py index e09320f2..672d4e04 100644 --- a/Tekst-API/tekst/errors.py +++ b/Tekst-API/tekst/errors.py @@ -248,6 +248,12 @@ def error_instance( msg="Referenced resource ID in updates doesn't match the one in target content", ) +E_400_MESSAGE_TO_SELF = error_instance( + status_code=status.HTTP_400_BAD_REQUEST, + key="messageToSelf", + msg="You're not supposed to send a message to yourself", +) + E_400_CONTENT_TYPE_MISMATCH = error_instance( status_code=status.HTTP_400_BAD_REQUEST, key="contentTypeMismatch", diff --git a/Tekst-API/tekst/models/message.py b/Tekst-API/tekst/models/message.py new file mode 100644 index 00000000..e95016db --- /dev/null +++ b/Tekst-API/tekst/models/message.py @@ -0,0 +1,77 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import Field, StringConstraints + +from tekst.models.common import ( + DocumentBase, + ModelBase, + ModelFactoryMixin, + PydanticObjectId, + ReadBase, +) +from tekst.models.user import UserReadPublic +from tekst.utils import validators as val + + +class Message(ModelBase, ModelFactoryMixin): + sender: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the sender or None if this is a system message", + ), + ] = None + recipient: Annotated[ + PydanticObjectId, + Field( + description="ID of the recipient", + ), + ] + content: Annotated[ + str, + StringConstraints( + min_length=1, + max_length=8000, + strip_whitespace=True, + ), + val.CleanupMultiline, + Field( + description="Content of the message", + ), + ] + time: Annotated[ + datetime, + Field( + description="Time when the message was sent", + ), + ] + thread_id: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the message thread this message belongs to", + ), + ] = None + deleted: Annotated[ + PydanticObjectId | None, + Field( + description="ID of the user who deleted the message or None if not deleted", + ), + ] = None + + +class MessageDocument(Message, DocumentBase): + class Settings(DocumentBase.Settings): + name = "messages" + indexes = [ + "recipient", + "sender", + "thread_id", + ] + + +class MessageRead(Message, ReadBase): + sender_user: UserReadPublic + recipient_user: UserReadPublic + + +MessageCreate = Message.create_model() diff --git a/Tekst-API/tekst/models/user.py b/Tekst-API/tekst/models/user.py index 4b9138f7..96ffabff 100644 --- a/Tekst-API/tekst/models/user.py +++ b/Tekst-API/tekst/models/user.py @@ -73,7 +73,7 @@ class UserReadPublic(ModelBase): affiliation: str | None = None avatar_url: str | None = None bio: str | None = None - is_superuser: bool + is_superuser: bool = False public_fields: MaybePrivateUserFields = [] @model_validator(mode="after") diff --git a/Tekst-API/tekst/openapi/tags_metadata.py b/Tekst-API/tekst/openapi/tags_metadata.py index ab42efee..983c78a4 100644 --- a/Tekst-API/tekst/openapi/tags_metadata.py +++ b/Tekst-API/tekst/openapi/tags_metadata.py @@ -67,6 +67,14 @@ def get_tags_metadata(documentation_url: str) -> list[dict[str, Any]]: "url": documentation_url, }, }, + { + "name": "messages", + "description": "Messages users send and receive on the platform", + "externalDocs": { + "description": "View full documentation", + "url": documentation_url, + }, + }, { "name": "bookmarks", "description": "The current user's bookmarks", diff --git a/Tekst-API/tekst/routers/messages.py b/Tekst-API/tekst/routers/messages.py new file mode 100644 index 00000000..c7c560e3 --- /dev/null +++ b/Tekst-API/tekst/routers/messages.py @@ -0,0 +1,140 @@ +from typing import Annotated + +from beanie.operators import Eq, In, Or +from fastapi import APIRouter, Path, status + +from tekst import errors +from tekst.auth import ( + UserDep, +) +from tekst.models.common import PydanticObjectId +from tekst.models.message import MessageCreate, MessageDocument, MessageRead +from tekst.models.user import UserDocument, UserReadPublic + + +router = APIRouter( + prefix="/messages", + tags=["messages"], +) + + +@router.post( + "/messages", + status_code=status.HTTP_204_NO_CONTENT, + responses=errors.responses( + [ + errors.E_401_UNAUTHORIZED, + errors.E_404_USER_NOT_FOUND, + ] + ), +) +async def send_message( + user: UserDep, + message: MessageCreate, +) -> None: + """Creates a message for the specified recipient""" + # check if sender == recipient + if user.id == message.recipient: + raise errors.E_400_MESSAGE_TO_SELF + + # check if recipient exists + if not await UserDocument.find_one( + UserDocument.id == message.recipient, + Eq(UserDocument.is_active, True), + ).exists(): + raise errors.E_404_USER_NOT_FOUND + + # force some message values + message.sender = user.id + message.thread_id = ( + message.thread_id + if message.thread_id + and await MessageDocument.find_one( + MessageDocument.thread_id == message.thread_id + ).exists() + else PydanticObjectId() + ) + + # create message + await MessageDocument.model_from(message).create() + + +@router.get( + "/messages", + status_code=status.HTTP_200_OK, + response_model=list[MessageRead], + responses=errors.responses( + [ + errors.E_401_UNAUTHORIZED, + ] + ), +) +async def get_messages(user: UserDep) -> list[MessageRead]: + """Returns all messages for/from the requesting user""" + messages = await MessageDocument.find( + Or( + MessageDocument.recipient == user.id, + MessageDocument.sender == user.id, + ), + MessageDocument.deleted != user.id, + ).to_list() + + # get user IDs of all senders/recipients + user_ids = set() + for user_id_pair in [[m.sender, m.recipient] for m in messages]: + user_ids.update(user_id_pair) + + # get all relevant users + users = { + u.id: UserReadPublic(**u.model_dump(exclude={"bio"})) + for u in await UserDocument.find( + In(UserDocument.id, list(user_ids)), + ).to_list() + } + + # make messages list contain MessageRead instaces with user data + messages: list[MessageRead] = [ + MessageRead( + **dict( + sender_user=users[m.sender], + recipient_user=users[m.recipient], + **m.model_dump(), + ) + ) + for m in messages + ] + + return messages + + +@router.delete( + "/messages/{id}", + status_code=status.HTTP_204_NO_CONTENT, + responses=errors.responses( + [ + errors.E_401_UNAUTHORIZED, + errors.E_404_NOT_FOUND, + errors.E_403_FORBIDDEN, + ] + ), +) +async def delete_message( + user: UserDep, message_id: Annotated[PydanticObjectId, Path(alias="id")] +) -> None: + """Deletes the message with the given ID""" + msg: MessageDocument = await MessageDocument.get(message_id) + + # check if message exists + if not msg: + raise errors.E_404_NOT_FOUND + + # check if requesting user can delete message + if user.id != msg.sender and user.id != msg.recipient: + raise errors.E_403_FORBIDDEN + + # mark message as deleted or actually delete it depending on current deletion status + if not msg.sender or (msg.deleted and msg.deleted != user.id): + await msg.delete() + else: + msg.deleted = user.id + await msg.replace() From 164aa8df9571945e086075fe25b3eb2142b496fd Mon Sep 17 00:00:00 2001 From: bkis Date: Wed, 20 Mar 2024 08:53:06 +0100 Subject: [PATCH 02/14] Update API schema --- Tekst-API/openapi.json | 347 +++++++++++++++++++++++++++++++++- Tekst-Web/src/api/schema.d.ts | 200 +++++++++++++++++++- 2 files changed, 542 insertions(+), 5 deletions(-) diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index c11f6f46..9083e716 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -1797,6 +1797,186 @@ } } }, + "/messages/messages": { + "get": { + "tags": [ + "messages" + ], + "summary": "Get messages", + "description": "Returns all messages for/from the requesting user", + "operationId": "getMessages", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MessageRead" + }, + "type": "array", + "title": "Response Get Messages Messages Messages Get" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + } + }, + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ] + }, + "post": { + "tags": [ + "messages" + ], + "summary": "Send message", + "description": "Creates a message for the specified recipient", + "operationId": "sendMessage", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageCreate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/messages/messages/{id}": { + "delete": { + "tags": [ + "messages" + ], + "summary": "Delete message", + "description": "Deletes the message with the given ID", + "operationId": "deleteMessage", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593", + "title": "Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Not Found" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Forbidden" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/platform": { "get": { "tags": [ @@ -6820,6 +7000,159 @@ "type": "array", "maxItems": 3 }, + "MessageCreate": { + "properties": { + "sender": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Sender", + "description": "ID of the sender or None if this is a system message" + }, + "recipient": { + "type": "string", + "title": "Recipient", + "description": "ID of the recipient", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "content": { + "type": "string", + "maxLength": 8000, + "minLength": 1, + "title": "Content", + "description": "Content of the message" + }, + "time": { + "type": "string", + "format": "date-time", + "title": "Time", + "description": "Time when the message was sent" + }, + "threadId": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Threadid", + "description": "ID of the message thread this message belongs to" + }, + "deleted": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Deleted", + "description": "ID of the user who deleted the message or None if not deleted" + } + }, + "type": "object", + "required": [ + "recipient", + "content", + "time" + ], + "title": "MessageCreate" + }, + "MessageRead": { + "properties": { + "id": { + "type": "string", + "title": "Id", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "sender": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Sender", + "description": "ID of the sender or None if this is a system message" + }, + "recipient": { + "type": "string", + "title": "Recipient", + "description": "ID of the recipient", + "example": "5eb7cf5a86d9755df3a6c593" + }, + "content": { + "type": "string", + "maxLength": 8000, + "minLength": 1, + "title": "Content", + "description": "Content of the message" + }, + "time": { + "type": "string", + "format": "date-time", + "title": "Time", + "description": "Time when the message was sent" + }, + "threadId": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Threadid", + "description": "ID of the message thread this message belongs to" + }, + "deleted": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "type": "null" + } + ], + "title": "Deleted", + "description": "ID of the user who deleted the message or None if not deleted" + }, + "senderUser": { + "$ref": "#/components/schemas/UserReadPublic" + }, + "recipientUser": { + "$ref": "#/components/schemas/UserReadPublic" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "id", + "recipient", + "content", + "time", + "senderUser", + "recipientUser" + ], + "title": "MessageRead" + }, "Metadate": { "properties": { "key": { @@ -10210,7 +10543,8 @@ }, "isSuperuser": { "type": "boolean", - "title": "Issuperuser" + "title": "Issuperuser", + "default": false }, "publicFields": { "allOf": [ @@ -10224,8 +10558,7 @@ "type": "object", "required": [ "id", - "username", - "isSuperuser" + "username" ], "title": "UserReadPublic" }, @@ -10515,6 +10848,14 @@ "url": "https://vedawebproject.github.io/Tekst" } }, + { + "name": "messages", + "description": "Messages users send and receive on the platform", + "externalDocs": { + "description": "View full documentation", + "url": "https://vedawebproject.github.io/Tekst" + } + }, { "name": "bookmarks", "description": "The current user's bookmarks", diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 7309df6a..48336112 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -132,6 +132,25 @@ export interface paths { */ post: operations['moveLocation']; }; + '/messages/messages': { + /** + * Get messages + * @description Returns all messages for/from the requesting user + */ + get: operations['getMessages']; + /** + * Send message + * @description Creates a message for the specified recipient + */ + post: operations['sendMessage']; + }; + '/messages/messages/{id}': { + /** + * Delete message + * @description Deletes the message with the given ID + */ + delete: operations['deleteMessage']; + }; '/platform': { /** * Get platform data @@ -966,6 +985,84 @@ export interface components { /** @enum {string} */ MaybePrivateUserField: 'name' | 'affiliation' | 'bio'; MaybePrivateUserFields: components['schemas']['MaybePrivateUserField'][]; + /** MessageCreate */ + MessageCreate: { + /** + * Sender + * @description ID of the sender or None if this is a system message + */ + sender?: string | null; + /** + * Recipient + * @description ID of the recipient + * @example 5eb7cf5a86d9755df3a6c593 + */ + recipient: string; + /** + * Content + * @description Content of the message + */ + content: string; + /** + * Time + * Format: date-time + * @description Time when the message was sent + */ + time: string; + /** + * Threadid + * @description ID of the message thread this message belongs to + */ + threadId?: string | null; + /** + * Deleted + * @description ID of the user who deleted the message or None if not deleted + */ + deleted?: string | null; + }; + /** MessageRead */ + MessageRead: { + /** + * Id + * @example 5eb7cf5a86d9755df3a6c593 + */ + id: string; + /** + * Sender + * @description ID of the sender or None if this is a system message + */ + sender?: string | null; + /** + * Recipient + * @description ID of the recipient + * @example 5eb7cf5a86d9755df3a6c593 + */ + recipient: string; + /** + * Content + * @description Content of the message + */ + content: string; + /** + * Time + * Format: date-time + * @description Time when the message was sent + */ + time: string; + /** + * Threadid + * @description ID of the message thread this message belongs to + */ + threadId?: string | null; + /** + * Deleted + * @description ID of the user who deleted the message or None if not deleted + */ + deleted?: string | null; + senderUser: components['schemas']['UserReadPublic']; + recipientUser: components['schemas']['UserReadPublic']; + [key: string]: unknown; + }; /** Metadate */ Metadate: { /** Key */ @@ -2711,8 +2808,11 @@ export interface components { avatarUrl?: string | null; /** Bio */ bio?: string | null; - /** Issuperuser */ - isSuperuser: boolean; + /** + * Issuperuser + * @default false + */ + isSuperuser?: boolean; /** @default [] */ publicFields?: components['schemas']['MaybePrivateUserFields']; }; @@ -3571,6 +3671,102 @@ export interface operations { }; }; }; + /** + * Get messages + * @description Returns all messages for/from the requesting user + */ + getMessages: { + responses: { + /** @description Successful Response */ + 200: { + content: { + 'application/json': components['schemas']['MessageRead'][]; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + }; + }; + /** + * Send message + * @description Creates a message for the specified recipient + */ + sendMessage: { + requestBody: { + content: { + 'application/json': components['schemas']['MessageCreate']; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + content: never; + }; + /** @description Unauthorized */ + 401: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Not Found */ + 404: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + /** + * Delete message + * @description Deletes the message with the given ID + */ + deleteMessage: { + parameters: { + path: { + id: string; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + content: never; + }; + /** @description Unauthorized */ + 401: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Forbidden */ + 403: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Not Found */ + 404: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; /** * Get platform data * @description Returns data the client needs to initialize From 13fa5352e7b8b407065050eb8b66e5ee00ba70b6 Mon Sep 17 00:00:00 2001 From: bkis Date: Wed, 20 Mar 2024 17:41:46 +0100 Subject: [PATCH 03/14] Implement basic messaging functionality --- Tekst-API/openapi.json | 237 +++++++++++++--- Tekst-API/tekst/db.py | 2 +- Tekst-API/tekst/models/message.py | 15 +- Tekst-API/tekst/routers/messages.py | 99 +++++-- Tekst-Web/src/api/index.ts | 5 + Tekst-Web/src/api/schema.d.ts | 116 ++++++-- .../src/components/browse/BrowseToolbar.vue | 2 +- .../src/components/generic/GenericModal.vue | 14 + .../navigation/UserActionsButton.vue | 55 ++-- .../components/navigation/navMenuOptions.ts | 25 +- Tekst-Web/src/components/user/UserDisplay.vue | 12 +- .../src/components/user/UserDisplayText.vue | 18 +- Tekst-Web/src/composables/bookmarks.ts | 2 + Tekst-Web/src/composables/userMessages.ts | 144 ++++++++++ Tekst-Web/src/icons.ts | 8 + Tekst-Web/src/router.ts | 6 + Tekst-Web/src/stores/auth.ts | 9 +- .../src/views/account/AccountMessagesView.vue | 255 ++++++++++++++++++ Tekst-Web/src/views/account/AccountView.vue | 4 +- .../help/deDE/accountMessagesView.md | 3 + .../help/enUS/accountMessagesView.md | 3 + Tekst-Web/translations/ui/deDE.yml | 10 + Tekst-Web/translations/ui/enUS.yml | 10 + 23 files changed, 930 insertions(+), 124 deletions(-) create mode 100644 Tekst-Web/src/composables/userMessages.ts create mode 100644 Tekst-Web/src/views/account/AccountMessagesView.vue create mode 100644 Tekst-Web/translations/help/deDE/accountMessagesView.md create mode 100644 Tekst-Web/translations/help/enUS/accountMessagesView.md diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index 9083e716..dcbd0bb0 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -1797,7 +1797,7 @@ } } }, - "/messages/messages": { + "/messages": { "get": { "tags": [ "messages" @@ -1815,7 +1815,7 @@ "$ref": "#/components/schemas/MessageRead" }, "type": "array", - "title": "Response Get Messages Messages Messages Get" + "title": "Response Get Messages Messages Get" } } } @@ -1858,8 +1858,19 @@ "required": true }, "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MessageRead" + }, + "type": "array", + "title": "Response Send Message Messages Post" + } + } + } }, "401": { "description": "Unauthorized", @@ -1902,7 +1913,7 @@ ] } }, - "/messages/messages/{id}": { + "/messages/{id}": { "delete": { "tags": [ "messages" @@ -1931,8 +1942,19 @@ } ], "responses": { - "204": { - "description": "Successful Response" + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageRead" + }, + "title": "Response Delete Message Messages Id Delete" + } + } + } }, "401": { "content": { @@ -1977,6 +1999,152 @@ } } }, + "/messages/threads/{id}": { + "delete": { + "tags": [ + "messages" + ], + "summary": "Delete thread", + "description": "Marks all received messages from the given user as deleted or actually deletes them,\ndepending on the current deletion status", + "operationId": "deleteThread", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "const": "system" + } + ], + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageRead" + }, + "title": "Response Delete Thread Messages Threads Id Delete" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/messages/threads/{id}/read": { + "post": { + "tags": [ + "messages" + ], + "summary": "Mark thread read", + "description": "Marks all received messages from the given user as read", + "operationId": "markThreadRead", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "anyOf": [ + { + "type": "string", + "example": "5eb7cf5a86d9755df3a6c593" + }, + { + "const": "system" + } + ], + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageRead" + }, + "title": "Response Mark Thread Read Messages Threads Id Read Post" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TekstErrorModel" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/platform": { "get": { "tags": [ @@ -7023,7 +7191,7 @@ }, "content": { "type": "string", - "maxLength": 8000, + "maxLength": 1000, "minLength": 1, "title": "Content", "description": "Content of the message" @@ -7032,20 +7200,14 @@ "type": "string", "format": "date-time", "title": "Time", - "description": "Time when the message was sent" + "description": "Time when the message was sent", + "default": "2024-03-20T16:40:56.509092" }, - "threadId": { - "anyOf": [ - { - "type": "string", - "example": "5eb7cf5a86d9755df3a6c593" - }, - { - "type": "null" - } - ], - "title": "Threadid", - "description": "ID of the message thread this message belongs to" + "read": { + "type": "boolean", + "title": "Read", + "description": "Whether the message has been read by the recipient", + "default": false }, "deleted": { "anyOf": [ @@ -7064,8 +7226,7 @@ "type": "object", "required": [ "recipient", - "content", - "time" + "content" ], "title": "MessageCreate" }, @@ -7097,7 +7258,7 @@ }, "content": { "type": "string", - "maxLength": 8000, + "maxLength": 1000, "minLength": 1, "title": "Content", "description": "Content of the message" @@ -7106,9 +7267,16 @@ "type": "string", "format": "date-time", "title": "Time", - "description": "Time when the message was sent" + "description": "Time when the message was sent", + "default": "2024-03-20T16:40:56.509092" }, - "threadId": { + "read": { + "type": "boolean", + "title": "Read", + "description": "Whether the message has been read by the recipient", + "default": false + }, + "deleted": { "anyOf": [ { "type": "string", @@ -7118,24 +7286,18 @@ "type": "null" } ], - "title": "Threadid", - "description": "ID of the message thread this message belongs to" + "title": "Deleted", + "description": "ID of the user who deleted the message or None if not deleted" }, - "deleted": { + "senderUser": { "anyOf": [ { - "type": "string", - "example": "5eb7cf5a86d9755df3a6c593" + "$ref": "#/components/schemas/UserReadPublic" }, { "type": "null" } - ], - "title": "Deleted", - "description": "ID of the user who deleted the message or None if not deleted" - }, - "senderUser": { - "$ref": "#/components/schemas/UserReadPublic" + ] }, "recipientUser": { "$ref": "#/components/schemas/UserReadPublic" @@ -7147,7 +7309,6 @@ "id", "recipient", "content", - "time", "senderUser", "recipientUser" ], diff --git a/Tekst-API/tekst/db.py b/Tekst-API/tekst/db.py index 2485ba2e..0aae6fca 100644 --- a/Tekst-API/tekst/db.py +++ b/Tekst-API/tekst/db.py @@ -9,12 +9,12 @@ from tekst.models.bookmark import BookmarkDocument from tekst.models.content import ContentBaseDocument from tekst.models.location import LocationDocument +from tekst.models.message import MessageDocument from tekst.models.resource import ResourceBaseDocument from tekst.models.segment import ClientSegmentDocument from tekst.models.settings import PlatformSettingsDocument from tekst.models.text import TextDocument from tekst.models.user import UserDocument -from tekst.models.message import MessageDocument from tekst.resources import resource_types_mgr diff --git a/Tekst-API/tekst/models/message.py b/Tekst-API/tekst/models/message.py index e95016db..06690080 100644 --- a/Tekst-API/tekst/models/message.py +++ b/Tekst-API/tekst/models/message.py @@ -31,7 +31,7 @@ class Message(ModelBase, ModelFactoryMixin): str, StringConstraints( min_length=1, - max_length=8000, + max_length=1000, strip_whitespace=True, ), val.CleanupMultiline, @@ -44,13 +44,13 @@ class Message(ModelBase, ModelFactoryMixin): Field( description="Time when the message was sent", ), - ] - thread_id: Annotated[ - PydanticObjectId | None, + ] = datetime.utcnow() + read: Annotated[ + bool, Field( - description="ID of the message thread this message belongs to", + description="Whether the message has been read by the recipient", ), - ] = None + ] = False deleted: Annotated[ PydanticObjectId | None, Field( @@ -65,12 +65,11 @@ class Settings(DocumentBase.Settings): indexes = [ "recipient", "sender", - "thread_id", ] class MessageRead(Message, ReadBase): - sender_user: UserReadPublic + sender_user: UserReadPublic | None recipient_user: UserReadPublic diff --git a/Tekst-API/tekst/routers/messages.py b/Tekst-API/tekst/routers/messages.py index c7c560e3..3bfa040f 100644 --- a/Tekst-API/tekst/routers/messages.py +++ b/Tekst-API/tekst/routers/messages.py @@ -1,6 +1,7 @@ -from typing import Annotated +from datetime import datetime +from typing import Annotated, Literal -from beanie.operators import Eq, In, Or +from beanie.operators import And, Eq, In, Or, Set from fastapi import APIRouter, Path, status from tekst import errors @@ -19,8 +20,9 @@ @router.post( - "/messages", - status_code=status.HTTP_204_NO_CONTENT, + "", + status_code=status.HTTP_200_OK, + response_model=list[MessageRead], responses=errors.responses( [ errors.E_401_UNAUTHORIZED, @@ -31,7 +33,7 @@ async def send_message( user: UserDep, message: MessageCreate, -) -> None: +) -> list[MessageRead]: """Creates a message for the specified recipient""" # check if sender == recipient if user.id == message.recipient: @@ -46,21 +48,17 @@ async def send_message( # force some message values message.sender = user.id - message.thread_id = ( - message.thread_id - if message.thread_id - and await MessageDocument.find_one( - MessageDocument.thread_id == message.thread_id - ).exists() - else PydanticObjectId() - ) + message.time = datetime.utcnow() + message.deleted = None + message.read = False # create message await MessageDocument.model_from(message).create() + return await get_messages(user) @router.get( - "/messages", + "", status_code=status.HTTP_200_OK, response_model=list[MessageRead], responses=errors.responses( @@ -82,7 +80,7 @@ async def get_messages(user: UserDep) -> list[MessageRead]: # get user IDs of all senders/recipients user_ids = set() for user_id_pair in [[m.sender, m.recipient] for m in messages]: - user_ids.update(user_id_pair) + user_ids.update([uid for uid in user_id_pair if uid]) # get all relevant users users = { @@ -96,8 +94,8 @@ async def get_messages(user: UserDep) -> list[MessageRead]: messages: list[MessageRead] = [ MessageRead( **dict( - sender_user=users[m.sender], - recipient_user=users[m.recipient], + sender_user=users.get(m.sender, "system"), + recipient_user=users.get(m.recipient, None), **m.model_dump(), ) ) @@ -108,8 +106,9 @@ async def get_messages(user: UserDep) -> list[MessageRead]: @router.delete( - "/messages/{id}", - status_code=status.HTTP_204_NO_CONTENT, + "/{id}", + status_code=status.HTTP_200_OK, + response_model=list[MessageRead], responses=errors.responses( [ errors.E_401_UNAUTHORIZED, @@ -120,7 +119,7 @@ async def get_messages(user: UserDep) -> list[MessageRead]: ) async def delete_message( user: UserDep, message_id: Annotated[PydanticObjectId, Path(alias="id")] -) -> None: +) -> list[MessageRead]: """Deletes the message with the given ID""" msg: MessageDocument = await MessageDocument.get(message_id) @@ -138,3 +137,63 @@ async def delete_message( else: msg.deleted = user.id await msg.replace() + + return await get_messages(user) + + +@router.delete( + "/threads/{id}", + status_code=status.HTTP_200_OK, + response_model=list[MessageRead], + responses=errors.responses( + [ + errors.E_401_UNAUTHORIZED, + ] + ), +) +async def delete_thread( + user: UserDep, + sender_id: Annotated[PydanticObjectId | Literal["system"], Path(alias="id")], +) -> list[MessageRead]: + """ + Marks all received messages from the given user as deleted or actually deletes them, + depending on the current deletion status + """ + for msg in await MessageDocument.find( + Or( + And( + Eq( + MessageDocument.sender, None if sender_id == "system" else sender_id + ), + Eq(MessageDocument.recipient, user.id), + ), + And( + Eq(MessageDocument.sender, user.id), + Eq(MessageDocument.recipient, sender_id), + ), + ) + ).to_list(): + await delete_message(user, msg.id) + return await get_messages(user) + + +@router.post( + "/threads/{id}/read", + status_code=status.HTTP_200_OK, + response_model=list[MessageRead], + responses=errors.responses( + [ + errors.E_401_UNAUTHORIZED, + ] + ), +) +async def mark_thread_read( + user: UserDep, + sender_id: Annotated[PydanticObjectId | Literal["system"], Path(alias="id")], +) -> list[MessageRead]: + """Marks all received messages from the given user as read""" + await MessageDocument.find( + Eq(MessageDocument.sender, None if sender_id == "system" else sender_id), + Eq(MessageDocument.recipient, user.id), + ).update(Set({MessageDocument.read: True})) + return await get_messages(user) diff --git a/Tekst-Web/src/api/index.ts b/Tekst-Web/src/api/index.ts index e06f2cc1..99c27057 100644 --- a/Tekst-Web/src/api/index.ts +++ b/Tekst-Web/src/api/index.ts @@ -148,6 +148,11 @@ export type UserUpdateAdminNotificationTriggers = components['schemas']['UserUpdate']['adminNotificationTriggers']; export type UserUpdatePublicFields = components['schemas']['UserUpdate']['publicFields']; +// user messages + +export type UserMessageCreate = components['schemas']['MessageCreate']; +export type UserMessageRead = components['schemas']['MessageRead']; + // text and text structure export type TextCreate = components['schemas']['TextCreate']; diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 48336112..9a7f88ce 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -132,7 +132,7 @@ export interface paths { */ post: operations['moveLocation']; }; - '/messages/messages': { + '/messages': { /** * Get messages * @description Returns all messages for/from the requesting user @@ -144,13 +144,28 @@ export interface paths { */ post: operations['sendMessage']; }; - '/messages/messages/{id}': { + '/messages/{id}': { /** * Delete message * @description Deletes the message with the given ID */ delete: operations['deleteMessage']; }; + '/messages/threads/{id}': { + /** + * Delete thread + * @description Marks all received messages from the given user as deleted or actually deletes them, + * depending on the current deletion status + */ + delete: operations['deleteThread']; + }; + '/messages/threads/{id}/read': { + /** + * Mark thread read + * @description Marks all received messages from the given user as read + */ + post: operations['markThreadRead']; + }; '/platform': { /** * Get platform data @@ -1007,13 +1022,15 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent + * @default 2024-03-20T16:40:56.509092 */ - time: string; + time?: string; /** - * Threadid - * @description ID of the message thread this message belongs to + * Read + * @description Whether the message has been read by the recipient + * @default false */ - threadId?: string | null; + read?: boolean; /** * Deleted * @description ID of the user who deleted the message or None if not deleted @@ -1047,19 +1064,21 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent + * @default 2024-03-20T16:40:56.509092 */ - time: string; + time?: string; /** - * Threadid - * @description ID of the message thread this message belongs to + * Read + * @description Whether the message has been read by the recipient + * @default false */ - threadId?: string | null; + read?: boolean; /** * Deleted * @description ID of the user who deleted the message or None if not deleted */ deleted?: string | null; - senderUser: components['schemas']['UserReadPublic']; + senderUser: components['schemas']['UserReadPublic'] | null; recipientUser: components['schemas']['UserReadPublic']; [key: string]: unknown; }; @@ -3703,8 +3722,10 @@ export interface operations { }; responses: { /** @description Successful Response */ - 204: { - content: never; + 200: { + content: { + 'application/json': components['schemas']['MessageRead'][]; + }; }; /** @description Unauthorized */ 401: { @@ -3738,8 +3759,10 @@ export interface operations { }; responses: { /** @description Successful Response */ - 204: { - content: never; + 200: { + content: { + 'application/json': components['schemas']['MessageRead'][]; + }; }; /** @description Unauthorized */ 401: { @@ -3767,6 +3790,69 @@ export interface operations { }; }; }; + /** + * Delete thread + * @description Marks all received messages from the given user as deleted or actually deletes them, + * depending on the current deletion status + */ + deleteThread: { + parameters: { + path: { + id: string | 'system'; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + 'application/json': components['schemas']['MessageRead'][]; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + /** + * Mark thread read + * @description Marks all received messages from the given user as read + */ + markThreadRead: { + parameters: { + path: { + id: string | 'system'; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + 'application/json': components['schemas']['MessageRead'][]; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + 'application/json': components['schemas']['TekstErrorModel']; + }; + }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; /** * Get platform data * @description Returns data the client needs to initialize diff --git a/Tekst-Web/src/components/browse/BrowseToolbar.vue b/Tekst-Web/src/components/browse/BrowseToolbar.vue index c1fcc984..7ffe0525 100644 --- a/Tekst-Web/src/components/browse/BrowseToolbar.vue +++ b/Tekst-Web/src/components/browse/BrowseToolbar.vue @@ -151,7 +151,7 @@ const buttonSize = computed(() => (state.smallScreen ? 'small' : 'large')); diff --git a/Tekst-Web/src/components/generic/GenericModal.vue b/Tekst-Web/src/components/generic/GenericModal.vue index e9d696fe..f9cd6662 100644 --- a/Tekst-Web/src/components/generic/GenericModal.vue +++ b/Tekst-Web/src/components/generic/GenericModal.vue @@ -43,6 +43,8 @@ const emit = defineEmits([ const modalStyle = computed(() => ({ maxWidth: '95%', + marginTop: 'var(--layout-gap)', + marginBottom: 'var(--layout-gap)', width: { narrow: '480px', medium: '600px', @@ -86,6 +88,18 @@ function handleMaskClick(e: MouseEvent) { + + + + diff --git a/Tekst-Web/src/components/navigation/UserActionsButton.vue b/Tekst-Web/src/components/navigation/UserActionsButton.vue index 933893d2..24f76a65 100644 --- a/Tekst-Web/src/components/navigation/UserActionsButton.vue +++ b/Tekst-Web/src/components/navigation/UserActionsButton.vue @@ -2,12 +2,14 @@ import { ref, computed, h } from 'vue'; import { useAuthStore, useStateStore, useThemeStore } from '@/stores'; import { type RouteLocationRaw, RouterLink } from 'vue-router'; -import { NButton, NIcon, NDropdown } from 'naive-ui'; +import { NBadge, NButton, NIcon, NDropdown } from 'naive-ui'; import { $t } from '@/i18n'; import { LogInIcon, LogOutIcon, UserIcon, AdminIcon, ResourceIcon } from '@/icons'; import { renderIcon } from '@/utils'; +import { useUserMessages } from '@/composables/userMessages'; const auth = useAuthStore(); +const { unreadCount: unreadUserMessagesCount } = useUserMessages(); const state = useStateStore(); const theme = useThemeStore(); @@ -21,9 +23,20 @@ const showUserDropdown = ref(false); const userOptions = computed(() => [ { - label: renderLink(() => `${auth.user?.name}`, { - name: 'account', - }), + label: renderLink( + () => + h('div', null, [ + auth.user?.name, + h( + NBadge, + { dot: true, offset: [4, -10], show: !!unreadUserMessagesCount.value }, + undefined + ), + ]), + { + name: 'account', + } + ), key: 'account', icon: renderIcon(UserIcon), }, @@ -59,11 +72,7 @@ const userOptions = computed(() => [ }, ]); -function renderLink( - label: string | (() => string), - to: RouteLocationRaw, - props?: Record -) { +function renderLink(label: unknown, to: RouteLocationRaw, props?: Record) { return () => h( RouterLink, @@ -96,19 +105,21 @@ function handleUserOptionSelect(key: string) { trigger="click" @select="handleUserOptionSelect" > - - - + + + + + string), - to: RouteLocationRaw, - props?: Record -) { +function renderLink(label: unknown, to: RouteLocationRaw, props?: Record) { return () => h( RouterLink, @@ -108,6 +106,7 @@ export function useMainMenuOptions(showIcons: boolean = true) { } export function useAccountMenuOptions(showIcons: boolean = true) { + const { unreadCount } = useUserMessages(); const menuOptions: MenuOption[] = [ { label: renderLink(() => $t('account.profile'), { name: 'accountProfile' }), @@ -119,6 +118,20 @@ export function useAccountMenuOptions(showIcons: boolean = true) { key: 'accountSettings', icon: (showIcons && renderIcon(ManageAccountIcon)) || undefined, }, + { + label: renderLink( + () => + h('div', null, [ + $t('account.messages.heading'), + h(NBadge, { dot: true, offset: [4, -10], show: !!unreadCount.value }, undefined), + ]), + { + name: 'accountMessages', + } + ), + key: 'accountMessages', + icon: (showIcons && renderIcon(MesssageIcon)) || undefined, + }, ]; return { diff --git a/Tekst-Web/src/components/user/UserDisplay.vue b/Tekst-Web/src/components/user/UserDisplay.vue index 8a4e025d..ece86e9f 100644 --- a/Tekst-Web/src/components/user/UserDisplay.vue +++ b/Tekst-Web/src/components/user/UserDisplay.vue @@ -9,11 +9,13 @@ withDefaults( user?: UserReadPublic & Record; showAvatar?: boolean; size?: 'large' | 'medium' | 'small' | 'tiny'; + link?: boolean; }>(), { user: undefined, showAvatar: true, size: 'medium', + link: true, } ); @@ -34,10 +36,14 @@ const iconSizes = { v-if="showAvatar" :avatar-url="user?.avatarUrl || undefined" :size="iconSizes[size]" + style="flex-shrink: 0" /> - - - + diff --git a/Tekst-Web/src/components/user/UserDisplayText.vue b/Tekst-Web/src/components/user/UserDisplayText.vue index a39f22d7..31059fc1 100644 --- a/Tekst-Web/src/components/user/UserDisplayText.vue +++ b/Tekst-Web/src/components/user/UserDisplayText.vue @@ -7,12 +7,14 @@ defineProps<{ diff --git a/Tekst-Web/src/composables/bookmarks.ts b/Tekst-Web/src/composables/bookmarks.ts index 72be1ce2..381110af 100644 --- a/Tekst-Web/src/composables/bookmarks.ts +++ b/Tekst-Web/src/composables/bookmarks.ts @@ -21,6 +21,8 @@ export function useBookmarks() { } else { bookmarks.value = []; } + } else { + bookmarks.value = []; } } diff --git a/Tekst-Web/src/composables/userMessages.ts b/Tekst-Web/src/composables/userMessages.ts new file mode 100644 index 00000000..f77928db --- /dev/null +++ b/Tekst-Web/src/composables/userMessages.ts @@ -0,0 +1,144 @@ +import { computed, ref } from 'vue'; +import { DELETE, GET, POST } from '@/api'; +import type { UserMessageCreate, UserMessageRead, UserReadPublic } from '@/api'; +import { useAuthStore } from '@/stores'; +import { watchEffect } from 'vue'; +import { useMessages } from './messages'; +import { $t } from '@/i18n'; +import { usePlatformData } from './platformData'; + +export interface MessageThread { + id: string; + contact?: UserReadPublic; + contactLabel: string; + messages: UserMessageRead[]; + unreadCount: number; +} + +const userMessages = ref([]); +const lastUserId = ref(); + +export function useUserMessages() { + const auth = useAuthStore(); + const { pfData } = usePlatformData(); + const { message } = useMessages(); + + const loading = ref(false); + + const unreadCount = computed( + () => userMessages.value.filter((m) => (auth.user?.id || '–') === m.recipient && !m.read).length + ); + + const threads = computed(() => { + if (!auth.user?.id) return []; + const result: MessageThread[] = []; + for (const message of userMessages.value) { + const contact: UserReadPublic | undefined = + (message.recipientUser.id !== auth.user.id ? message.recipientUser : message.senderUser) || + undefined; + const threadId = contact?.id || 'system'; + const thread = result.find((t) => t.id === threadId); + if (!thread) { + result.push({ + id: threadId, + contact, + contactLabel: + contact?.name || + contact?.username || + pfData.value?.settings.infoPlatformName || + 'System', + messages: [message], + unreadCount: auth.user.id === message.recipient && !message.read ? 1 : 0, + }); + } else { + thread.messages.push(message); + if (auth.user.id === message.recipient && !message.read) thread.unreadCount++; + } + } + // sort messages in threads by time + for (const thread of result) { + thread.messages.sort( + (a, b) => new Date(a.time || '').getTime() - new Date(b.time || '').getTime() + ); + } + // sort threads by most current message + result.sort( + (a, b) => + new Date(a.messages[0].time || '').getTime() - new Date(b.messages[0].time || '').getTime() + ); + return result; + }); + + async function loadUserMessages() { + loading.value = true; + if (auth.user?.id) { + const { data, error } = await GET('/messages'); + if (!error) { + userMessages.value = data; + } else { + userMessages.value = []; + } + } else { + userMessages.value = []; + } + loading.value = false; + } + + async function createUserMessage(msg: UserMessageCreate) { + loading.value = true; + const { data, error } = await POST('/messages', { + body: msg, + }); + if (!error) { + userMessages.value = data; + message.success($t('account.messages.createSuccess'), undefined, 1); + } + loading.value = false; + } + + async function deleteUserMessage(id: string) { + loading.value = true; + const { data, error } = await DELETE('/messages/{id}', { + params: { path: { id } }, + }); + if (!error) { + userMessages.value = data; + message.success($t('account.messages.deleteSuccess')); + } else { + auth.logout(); + } + loading.value = false; + } + + async function deleteUserMessageThread(id: string) { + loading.value = true; + const { data, error } = await DELETE('/messages/threads/{id}', { + params: { path: { id } }, + }); + if (!error) { + userMessages.value = data; + message.success($t('account.messages.deleteSuccess')); + } else { + auth.logout(); + } + loading.value = false; + } + + watchEffect(() => { + if (auth.loggedIn && auth.user?.id !== lastUserId.value) { + lastUserId.value = auth.user?.id; + loadUserMessages(); + } + }); + + return { + userMessages, + threads, + loading, + unreadCount, + loadUserMessages, + createUserMessage, + deleteUserMessage, + deleteUserMessageThread, + }; +} diff --git a/Tekst-Web/src/icons.ts b/Tekst-Web/src/icons.ts index 5edc46ab..37dbee9d 100644 --- a/Tekst-Web/src/icons.ts +++ b/Tekst-Web/src/icons.ts @@ -104,8 +104,16 @@ import LockOpenOutlined from '@vicons/material/LockOpenOutlined'; import SortOutlined from '@vicons/material/SortOutlined'; import FilterAltOutlined from '@vicons/material/FilterAltOutlined'; import FindInPageOutlined from '@vicons/material/FindInPageOutlined'; +import MessageOutlined from '@vicons/material/MessageOutlined'; +import MarkChatReadRound from '@vicons/material/MarkChatReadRound'; +import MarkChatUnreadRound from '@vicons/material/MarkChatUnreadRound'; +import SendFilled from '@vicons/material/SendFilled'; export { + SendFilled as SendIcon, + MarkChatReadRound as MarkChatReadIcon, + MarkChatUnreadRound as MarkChatUnreadIcon, + MessageOutlined as MesssageIcon, FindInPageOutlined as NothingFoundIcon, FilterAltOutlined as FilterIcon, SortOutlined as SortIcon, diff --git a/Tekst-Web/src/router.ts b/Tekst-Web/src/router.ts index 58681e5f..13dcc2b0 100644 --- a/Tekst-Web/src/router.ts +++ b/Tekst-Web/src/router.ts @@ -27,6 +27,7 @@ const ContentsView = () => import('@/views/ContentsView.vue'); const AccountView = () => import('@/views/account/AccountView.vue'); const AccountSettingsView = () => import('@/views/account/AccountSettingsView.vue'); +const AccountMessagesView = () => import('@/views/account/AccountMessagesView.vue'); const VerifyView = () => import('@/views/VerifyView.vue'); const ResetView = () => import('@/views/ResetView.vue'); @@ -183,6 +184,11 @@ const router = createRouter({ name: 'accountSettings', component: AccountSettingsView, }, + { + path: 'messages', + name: 'accountMessages', + component: AccountMessagesView, + }, ], }, { diff --git a/Tekst-Web/src/stores/auth.ts b/Tekst-Web/src/stores/auth.ts index 4c185915..2abfd108 100644 --- a/Tekst-Web/src/stores/auth.ts +++ b/Tekst-Web/src/stores/auth.ts @@ -8,6 +8,7 @@ import { useIntervalFn } from '@vueuse/core'; import { useRouter, type RouteLocationRaw } from 'vue-router'; import { usePlatformData } from '@/composables/platformData'; import { useStateStore } from '@/stores'; +import { useUserMessages } from '@/composables/userMessages'; const SESSION_POLL_INTERVAL_S = 60; // check session expiry every n seconds const SESSION_EXPIRY_OFFSET_S = 10; // assume session expired n seconds early @@ -27,6 +28,7 @@ export const useAuthStore = defineStore('auth', () => { const { pfData, loadPlatformData } = usePlatformData(); const { message } = useMessages(); const state = useStateStore(); + const { unreadCount: unreadUserMessagesCount } = useUserMessages(); const user = ref(); const loggedIn = computed(() => !!user.value); @@ -146,7 +148,12 @@ export const useAuthStore = defineStore('auth', () => { }) ); } - message.success($t('general.welcome', { name: userData.name })); + message.success( + $t('general.welcome', { name: userData.name }) + + (unreadUserMessagesCount.value + ? ` ${$t('account.messages.msgUnreadCount', { count: unreadUserMessagesCount })}!` + : '') + ); closeLoginModal(); return true; } else { diff --git a/Tekst-Web/src/views/account/AccountMessagesView.vue b/Tekst-Web/src/views/account/AccountMessagesView.vue new file mode 100644 index 00000000..2b8cdbb4 --- /dev/null +++ b/Tekst-Web/src/views/account/AccountMessagesView.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/Tekst-Web/src/views/account/AccountView.vue b/Tekst-Web/src/views/account/AccountView.vue index 07376ab5..9e052953 100644 --- a/Tekst-Web/src/views/account/AccountView.vue +++ b/Tekst-Web/src/views/account/AccountView.vue @@ -2,12 +2,14 @@ import { RouterView } from 'vue-router'; import NavigationMenu from '@/components/navigation/NavigationMenu.vue'; import { useAccountMenuOptions } from '@/components/navigation/navMenuOptions'; +import { useStateStore } from '@/stores'; +const state = useStateStore(); const { menuOptions } = useAccountMenuOptions(); diff --git a/Tekst-Web/translations/help/deDE/accountMessagesView.md b/Tekst-Web/translations/help/deDE/accountMessagesView.md new file mode 100644 index 00000000..d85b27d3 --- /dev/null +++ b/Tekst-Web/translations/help/deDE/accountMessagesView.md @@ -0,0 +1,3 @@ +## Schade! + +Dieser Hilfe-Text existiert **leider** noch nicht. diff --git a/Tekst-Web/translations/help/enUS/accountMessagesView.md b/Tekst-Web/translations/help/enUS/accountMessagesView.md new file mode 100644 index 00000000..50ff9a00 --- /dev/null +++ b/Tekst-Web/translations/help/enUS/accountMessagesView.md @@ -0,0 +1,3 @@ +## Too bad! + +This help text **unfortunately** doesn't exist, yet. diff --git a/Tekst-Web/translations/ui/deDE.yml b/Tekst-Web/translations/ui/deDE.yml index 02170a13..420bc60e 100644 --- a/Tekst-Web/translations/ui/deDE.yml +++ b/Tekst-Web/translations/ui/deDE.yml @@ -481,6 +481,16 @@ account: msgDeleteAccountWarning: Wenn Sie Ihr Konto löschen, werden alle Daten, die damit verbunden sind, emenfalls gelöscht. Sind Sie sich sicher? msgUserDataSaveSuccess: Benutzerdaten gespeichert. phDeleteAccountSafetyInput: Geben Sie "{username}" ein, um Ihre Entscheidung zu bestätigen + messages: + heading: Nachrichten + msgUnreadCount: 'Sie haben keine ungelesenen Nachrichten | Sie haben eine ungelesenen Nachricht | Sie haben {count} ungelesenen Nachrichten' + createSuccess: Nachricht gesendet + deleteSuccess: Nachricht gelöscht + read: Nachricht wurde gelesen + unread: Nachricht ist ungelesen + inputPlaceholder: Schreiben Sie Ihre Nachricht hier... + btnSend: Senden + msgNoMessages: Sie haben keine Nachrichten gesendet oder erhalten. admin: heading: Administration diff --git a/Tekst-Web/translations/ui/enUS.yml b/Tekst-Web/translations/ui/enUS.yml index 079adcfb..ebdb6a98 100644 --- a/Tekst-Web/translations/ui/enUS.yml +++ b/Tekst-Web/translations/ui/enUS.yml @@ -472,6 +472,16 @@ account: msgDeleteAccountWarning: If you delete your account, all data associated with it will be deleted, too. Are you sure you want to delete your account? msgUserDataSaveSuccess: User data saved. phDeleteAccountSafetyInput: Type "{username}" to confirm your decision + messages: + heading: Messages + msgUnreadCount: 'You have no unread messages | You have one unread message | You have {count} unread messages' + createSuccess: Message sent + deleteSuccess: Message deleted + read: Message has been read + unread: Message is unread + inputPlaceholder: Type your message here... + btnSend: Send + msgNoMessages: You haven't sent or received any messages. admin: heading: Administration From 74ca2b72b8f09383781612b4a58dabeedb3a479e Mon Sep 17 00:00:00 2001 From: bkis Date: Thu, 21 Mar 2024 09:21:27 +0100 Subject: [PATCH 04/14] Improve user messaging, add message button to profile page --- Tekst-Web/src/App.vue | 3 +- Tekst-Web/src/assets/main.css | 2 +- .../src/components/browse/BookmarksWidget.vue | 2 +- .../browse/ContentHeaderWidgetBar.vue | 2 +- .../browse/ResourceToggleDrawer.vue | 2 +- .../navigation/UserActionsButton.vue | 13 +- .../components/navigation/navMenuOptions.ts | 11 +- .../resource/ContentCommentWidget.vue | 4 +- .../userMessages/MessagingModal.vue | 169 ++++++++++++++ Tekst-Web/src/icons.ts | 2 +- Tekst-Web/src/router.ts | 3 +- Tekst-Web/src/stores/auth.ts | 9 +- Tekst-Web/src/stores/index.ts | 1 + Tekst-Web/src/stores/state.ts | 7 +- .../{composables => stores}/userMessages.ts | 60 ++--- Tekst-Web/src/views/UserView.vue | 51 +++- .../src/views/account/AccountMessagesView.vue | 219 ++---------------- Tekst-Web/translations/ui/deDE.yml | 1 + Tekst-Web/translations/ui/enUS.yml | 1 + docs/generated/help/accountMessagesView.md | 3 + 20 files changed, 303 insertions(+), 262 deletions(-) create mode 100644 Tekst-Web/src/components/userMessages/MessagingModal.vue rename Tekst-Web/src/{composables => stores}/userMessages.ts (72%) create mode 100644 docs/generated/help/accountMessagesView.md diff --git a/Tekst-Web/src/App.vue b/Tekst-Web/src/App.vue index c9f17f72..693dd8d8 100644 --- a/Tekst-Web/src/App.vue +++ b/Tekst-Web/src/App.vue @@ -18,7 +18,7 @@ import PageFooter from './layout/PageFooter.vue'; import { useInitializeApp } from '@/composables/init'; import LoginModal from '@/components/modals/LoginModal.vue'; import HugeLabelledIcon from '@/components/generic/HugeLabelledIcon.vue'; - +import MessagingModal from '@/components/userMessages/MessagingModal.vue'; import { ErrorIcon } from '@/icons'; const state = useStateStore(); @@ -66,6 +66,7 @@ const nUiDateLocale = computed(() => getLocaleProfile(state.locale)?.nUiDateLoca /> + diff --git a/Tekst-Web/src/assets/main.css b/Tekst-Web/src/assets/main.css index 16971553..9ca79e05 100644 --- a/Tekst-Web/src/assets/main.css +++ b/Tekst-Web/src/assets/main.css @@ -60,7 +60,7 @@ h1 { } h2 { - font-size: 1.35rem; + font-size: 1.3rem; font-weight: var(--font-weight-normal); margin-top: 1.1rem; margin-bottom: 1rem; diff --git a/Tekst-Web/src/components/browse/BookmarksWidget.vue b/Tekst-Web/src/components/browse/BookmarksWidget.vue index a0428ede..9d3c0314 100644 --- a/Tekst-Web/src/components/browse/BookmarksWidget.vue +++ b/Tekst-Web/src/components/browse/BookmarksWidget.vue @@ -8,7 +8,7 @@ import { useRouter } from 'vue-router'; import PromptModal from '@/components/generic/PromptModal.vue'; import { $t } from '@/i18n'; import { useMessages } from '@/composables/messages'; -import GenericModal from '../generic/GenericModal.vue'; +import GenericModal from '@/components/generic/GenericModal.vue'; import LocationLabel from '@/components/LocationLabel.vue'; import { useBookmarks } from '@/composables/bookmarks'; import { bookmarkFormRules } from '@/forms/formRules'; diff --git a/Tekst-Web/src/components/browse/ContentHeaderWidgetBar.vue b/Tekst-Web/src/components/browse/ContentHeaderWidgetBar.vue index 858e6e49..408f597a 100644 --- a/Tekst-Web/src/components/browse/ContentHeaderWidgetBar.vue +++ b/Tekst-Web/src/components/browse/ContentHeaderWidgetBar.vue @@ -6,7 +6,7 @@ import ResourceInfoWidget from '@/components/resource/ResourceInfoWidget.vue'; import ResourceDeactivateWidget from '@/components/resource/ResourceDeactivateWidget.vue'; import { useBrowseStore } from '@/stores'; import type { AnyResourceRead } from '@/api'; -import ContentCommentWidget from '../resource/ContentCommentWidget.vue'; +import ContentCommentWidget from '@/components/resource/ContentCommentWidget.vue'; withDefaults( defineProps<{ diff --git a/Tekst-Web/src/components/browse/ResourceToggleDrawer.vue b/Tekst-Web/src/components/browse/ResourceToggleDrawer.vue index c9225f05..39b86d1d 100644 --- a/Tekst-Web/src/components/browse/ResourceToggleDrawer.vue +++ b/Tekst-Web/src/components/browse/ResourceToggleDrawer.vue @@ -6,7 +6,7 @@ import ResourceToggleDrawerItem from '@/components/browse/ResourceToggleDrawerIt import IconHeading from '@/components/generic/IconHeading.vue'; import { CheckAllIcon, ResourceIcon, UncheckAllIcon } from '@/icons'; -import LabelledSwitch from '../LabelledSwitch.vue'; +import LabelledSwitch from '@/components/LabelledSwitch.vue'; const props = defineProps<{ show: boolean }>(); const emit = defineEmits<{ (e: 'update:show', show: boolean): void }>(); diff --git a/Tekst-Web/src/components/navigation/UserActionsButton.vue b/Tekst-Web/src/components/navigation/UserActionsButton.vue index 24f76a65..f2ea3575 100644 --- a/Tekst-Web/src/components/navigation/UserActionsButton.vue +++ b/Tekst-Web/src/components/navigation/UserActionsButton.vue @@ -1,15 +1,14 @@ + + + + diff --git a/Tekst-Web/src/icons.ts b/Tekst-Web/src/icons.ts index 37dbee9d..9d8b4e46 100644 --- a/Tekst-Web/src/icons.ts +++ b/Tekst-Web/src/icons.ts @@ -113,7 +113,7 @@ export { SendFilled as SendIcon, MarkChatReadRound as MarkChatReadIcon, MarkChatUnreadRound as MarkChatUnreadIcon, - MessageOutlined as MesssageIcon, + MessageOutlined as MessageIcon, FindInPageOutlined as NothingFoundIcon, FilterAltOutlined as FilterIcon, SortOutlined as SortIcon, diff --git a/Tekst-Web/src/router.ts b/Tekst-Web/src/router.ts index 13dcc2b0..2d20b3f9 100644 --- a/Tekst-Web/src/router.ts +++ b/Tekst-Web/src/router.ts @@ -2,9 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore, useStateStore } from '@/stores'; import { $t } from '@/i18n'; import { useMessages } from '@/composables/messages'; - import { SiteNoticeIcon, PrivacyIcon, InfoIcon } from '@/icons'; -import { WEB_PATH } from './common'; +import { WEB_PATH } from '@/common'; declare module 'vue-router' { interface RouteMeta { diff --git a/Tekst-Web/src/stores/auth.ts b/Tekst-Web/src/stores/auth.ts index 2abfd108..43c4628f 100644 --- a/Tekst-Web/src/stores/auth.ts +++ b/Tekst-Web/src/stores/auth.ts @@ -7,8 +7,7 @@ import { $t, getLocaleProfile } from '@/i18n'; import { useIntervalFn } from '@vueuse/core'; import { useRouter, type RouteLocationRaw } from 'vue-router'; import { usePlatformData } from '@/composables/platformData'; -import { useStateStore } from '@/stores'; -import { useUserMessages } from '@/composables/userMessages'; +import { useStateStore, useUserMessagesStore } from '@/stores'; const SESSION_POLL_INTERVAL_S = 60; // check session expiry every n seconds const SESSION_EXPIRY_OFFSET_S = 10; // assume session expired n seconds early @@ -28,7 +27,7 @@ export const useAuthStore = defineStore('auth', () => { const { pfData, loadPlatformData } = usePlatformData(); const { message } = useMessages(); const state = useStateStore(); - const { unreadCount: unreadUserMessagesCount } = useUserMessages(); + const userMessages = useUserMessagesStore(); const user = ref(); const loggedIn = computed(() => !!user.value); @@ -150,8 +149,8 @@ export const useAuthStore = defineStore('auth', () => { } message.success( $t('general.welcome', { name: userData.name }) + - (unreadUserMessagesCount.value - ? ` ${$t('account.messages.msgUnreadCount', { count: unreadUserMessagesCount })}!` + (userMessages.unreadCount + ? ` ${$t('account.messages.msgUnreadCount', { count: userMessages.unreadCount })}!` : '') ); closeLoginModal(); diff --git a/Tekst-Web/src/stores/index.ts b/Tekst-Web/src/stores/index.ts index b76854c9..be0e1823 100644 --- a/Tekst-Web/src/stores/index.ts +++ b/Tekst-Web/src/stores/index.ts @@ -4,3 +4,4 @@ export * from './browse'; export * from './resources'; export * from './search'; export * from './theme'; +export * from './userMessages'; diff --git a/Tekst-Web/src/stores/state.ts b/Tekst-Web/src/stores/state.ts index d0e16334..e8d2229a 100644 --- a/Tekst-Web/src/stores/state.ts +++ b/Tekst-Web/src/stores/state.ts @@ -156,11 +156,14 @@ export const useStateStore = defineStore('state', () => { ); // set page title - function setPageTitle(forRoute: RouteLocationNormalized = route) { + function setPageTitle( + forRoute: RouteLocationNormalized = route, + variables: Record = {} + ) { const title = $te(`routes.pageTitle.${String(forRoute.name)}`) ? $t(`routes.pageTitle.${String(forRoute.name)}`, { text: text.value?.title, - username: auth.user?.username, + ...variables, }) : undefined; const pfName = pfData.value?.settings.infoPlatformName; diff --git a/Tekst-Web/src/composables/userMessages.ts b/Tekst-Web/src/stores/userMessages.ts similarity index 72% rename from Tekst-Web/src/composables/userMessages.ts rename to Tekst-Web/src/stores/userMessages.ts index f77928db..f1c84163 100644 --- a/Tekst-Web/src/composables/userMessages.ts +++ b/Tekst-Web/src/stores/userMessages.ts @@ -3,11 +3,12 @@ import { DELETE, GET, POST } from '@/api'; import type { UserMessageCreate, UserMessageRead, UserReadPublic } from '@/api'; import { useAuthStore } from '@/stores'; import { watchEffect } from 'vue'; -import { useMessages } from './messages'; +import { useMessages } from '@/composables/messages'; import { $t } from '@/i18n'; -import { usePlatformData } from './platformData'; +import { usePlatformData } from '@/composables/platformData'; +import { defineStore } from 'pinia'; -export interface MessageThread { +export interface UserMessageThread { id: string; contact?: UserReadPublic; contactLabel: string; @@ -15,24 +16,25 @@ export interface MessageThread { unreadCount: number; } -const userMessages = ref([]); -const lastUserId = ref(); - -export function useUserMessages() { +export const useUserMessagesStore = defineStore('userMessages', () => { const auth = useAuthStore(); const { pfData } = usePlatformData(); const { message } = useMessages(); + const messages = ref([]); + const lastUserId = ref(); const loading = ref(false); + const openThread = ref(); + const showMessagingModal = ref(false); const unreadCount = computed( - () => userMessages.value.filter((m) => (auth.user?.id || '–') === m.recipient && !m.read).length + () => messages.value.filter((m) => (auth.user?.id || '–') === m.recipient && !m.read).length ); - const threads = computed(() => { + const threads = computed(() => { if (!auth.user?.id) return []; - const result: MessageThread[] = []; - for (const message of userMessages.value) { + const result: UserMessageThread[] = []; + for (const message of messages.value) { const contact: UserReadPublic | undefined = (message.recipientUser.id !== auth.user.id ? message.recipientUser : message.senderUser) || undefined; @@ -69,40 +71,40 @@ export function useUserMessages() { return result; }); - async function loadUserMessages() { + async function load() { loading.value = true; if (auth.user?.id) { const { data, error } = await GET('/messages'); if (!error) { - userMessages.value = data; + messages.value = data; } else { - userMessages.value = []; + messages.value = []; } } else { - userMessages.value = []; + messages.value = []; } loading.value = false; } - async function createUserMessage(msg: UserMessageCreate) { + async function send(msg: UserMessageCreate) { loading.value = true; const { data, error } = await POST('/messages', { body: msg, }); if (!error) { - userMessages.value = data; + messages.value = data; message.success($t('account.messages.createSuccess'), undefined, 1); } loading.value = false; } - async function deleteUserMessage(id: string) { + async function deleteMessage(id: string) { loading.value = true; const { data, error } = await DELETE('/messages/{id}', { params: { path: { id } }, }); if (!error) { - userMessages.value = data; + messages.value = data; message.success($t('account.messages.deleteSuccess')); } else { auth.logout(); @@ -110,13 +112,13 @@ export function useUserMessages() { loading.value = false; } - async function deleteUserMessageThread(id: string) { + async function deleteThread(id: string) { loading.value = true; const { data, error } = await DELETE('/messages/threads/{id}', { params: { path: { id } }, }); if (!error) { - userMessages.value = data; + messages.value = data; message.success($t('account.messages.deleteSuccess')); } else { auth.logout(); @@ -127,18 +129,20 @@ export function useUserMessages() { watchEffect(() => { if (auth.loggedIn && auth.user?.id !== lastUserId.value) { lastUserId.value = auth.user?.id; - loadUserMessages(); + load(); } }); return { - userMessages, + messages, threads, + openThread, loading, unreadCount, - loadUserMessages, - createUserMessage, - deleteUserMessage, - deleteUserMessageThread, + showMessagingModal, + load, + send, + deleteMessage, + deleteThread, }; -} +}); diff --git a/Tekst-Web/src/views/UserView.vue b/Tekst-Web/src/views/UserView.vue index 406dfea6..b8edd1b4 100644 --- a/Tekst-Web/src/views/UserView.vue +++ b/Tekst-Web/src/views/UserView.vue @@ -1,28 +1,58 @@ -
+
{{ $t('help.msgFoundCount', { count: helpTextsFiltered?.length }) }}
From 013c00048b71ebb6177b1ba0aaa890dd53b7d1d3 Mon Sep 17 00:00:00 2001 From: bkis Date: Thu, 21 Mar 2024 10:46:43 +0100 Subject: [PATCH 06/14] Refactor pagination data model --- Tekst-API/openapi.json | 56 ++++++++++++++++------- Tekst-API/tekst/models/search.py | 24 +++++++++- Tekst-API/tekst/routers/search.py | 5 +- Tekst-API/tekst/search/__init__.py | 8 ++-- Tekst-Web/src/api/schema.d.ts | 47 ++++++++++++------- Tekst-Web/src/views/SearchResultsView.vue | 6 ++- 6 files changed, 104 insertions(+), 42 deletions(-) diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index dcbd0bb0..cb112a05 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -5969,8 +5969,10 @@ ], "description": "General search settings", "default": { - "pg": 1, - "pgs": 10, + "pgn": { + "pg": 1, + "pgs": 10 + }, "strict": false } }, @@ -6857,17 +6859,17 @@ }, "GeneralSearchSettings": { "properties": { - "pg": { - "type": "integer", - "title": "Pg", - "description": "Page number", - "default": 1 - }, - "pgs": { - "type": "integer", - "title": "Pgs", - "description": "Page size", - "default": 10 + "pgn": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginationSettings" + } + ], + "description": "Pagination settings", + "default": { + "pg": 1, + "pgs": 10 + } }, "sort": { "anyOf": [ @@ -7201,7 +7203,7 @@ "format": "date-time", "title": "Time", "description": "Time when the message was sent", - "default": "2024-03-20T16:40:56.509092" + "default": "2024-03-21T09:43:31.186946" }, "read": { "type": "boolean", @@ -7268,7 +7270,7 @@ "format": "date-time", "title": "Time", "description": "Time when the message was sent", - "default": "2024-03-20T16:40:56.509092" + "default": "2024-03-21T09:43:31.186946" }, "read": { "type": "boolean", @@ -7401,6 +7403,24 @@ ], "title": "OskMode" }, + "PaginationSettings": { + "properties": { + "pg": { + "type": "integer", + "title": "Pg", + "description": "Page number", + "default": 1 + }, + "pgs": { + "type": "integer", + "title": "Pgs", + "description": "Page size", + "default": 10 + } + }, + "type": "object", + "title": "PaginationSettings" + }, "PlainTextContentCreate": { "properties": { "resourceId": { @@ -8794,8 +8814,10 @@ ], "description": "General search settings", "default": { - "pg": 1, - "pgs": 10, + "pgn": { + "pg": 1, + "pgs": 10 + }, "strict": false } }, diff --git a/Tekst-API/tekst/models/search.py b/Tekst-API/tekst/models/search.py index bfcb0692..dcc6d35c 100644 --- a/Tekst-API/tekst/models/search.py +++ b/Tekst-API/tekst/models/search.py @@ -74,7 +74,7 @@ def from_es_results( ) -class GeneralSearchSettings(ModelBase): +class PaginationSettings(ModelBase): page: Annotated[ int, conint(ge=1), @@ -91,6 +91,28 @@ class GeneralSearchSettings(ModelBase): description="Page size", ), ] = 10 + + def es_from(self) -> int: + return (self.page - 1) * self.page_size + + def es_size(self) -> int: + return self.page_size + + def mongo_skip(self) -> int: + return (self.page - 1) * self.page_size if self.page > 0 else 0 + + def mongo_limit(self) -> int: + return self.page_size + + +class GeneralSearchSettings(ModelBase): + pagination: Annotated[ + PaginationSettings, + Field( + alias="pgn", + description="Pagination settings", + ), + ] = PaginationSettings() sorting_preset: Annotated[ SortingPreset | None, Field( diff --git a/Tekst-API/tekst/routers/search.py b/Tekst-API/tekst/routers/search.py index 54efe4e8..8869e03f 100644 --- a/Tekst-API/tekst/routers/search.py +++ b/Tekst-API/tekst/routers/search.py @@ -39,8 +39,9 @@ async def perform_search( ], ) -> SearchResults: if ( - (body.settings_general.page - 1) * body.settings_general.page_size - ) + body.settings_general.page_size > 10000: + body.settings_general.pagination.es_from() + + body.settings_general.pagination.es_size() + ) > 10000: raise errors.E_400_REQUESTED_TOO_MANY_SEARCH_RESULTS if body.search_type == "quick": return await search.search_quick( diff --git a/Tekst-API/tekst/search/__init__.py b/Tekst-API/tekst/search/__init__.py index 0bb75305..2ac18faf 100644 --- a/Tekst-API/tekst/search/__init__.py +++ b/Tekst-API/tekst/search/__init__.py @@ -337,8 +337,8 @@ async def search_quick( highlight={ "fields": {"*": {}}, }, - from_=(settings_general.page - 1) * settings_general.page_size, - size=settings_general.page_size, + from_=settings_general.pagination.es_from(), + size=settings_general.pagination.es_size(), track_scores=True, sort=SORTING_PRESETS.get(settings_general.sorting_preset, None) if settings_general.sorting_preset @@ -389,8 +389,8 @@ async def search_advanced( highlight={ "fields": {"*": {}}, }, - from_=(settings_general.page - 1) * settings_general.page_size, - size=settings_general.page_size, + from_=settings_general.pagination.es_from(), + size=settings_general.pagination.es_size(), track_scores=True, sort=SORTING_PRESETS.get(settings_general.sorting_preset, None) if settings_general.sorting_preset diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 9a7f88ce..191e0b63 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -403,8 +403,10 @@ export interface components { /** * @description General search settings * @default { - * "pg": 1, - * "pgs": 10, + * "pgn": { + * "pg": 1, + * "pgs": 10 + * }, * "strict": false * } */ @@ -856,17 +858,13 @@ export interface components { /** GeneralSearchSettings */ GeneralSearchSettings: { /** - * Pg - * @description Page number - * @default 1 - */ - pg?: number; - /** - * Pgs - * @description Page size - * @default 10 + * @description Pagination settings + * @default { + * "pg": 1, + * "pgs": 10 + * } */ - pgs?: number; + pgn?: components['schemas']['PaginationSettings']; /** @description Sorting preset */ sort?: components['schemas']['SortingPreset'] | null; /** @@ -1022,7 +1020,7 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent - * @default 2024-03-20T16:40:56.509092 + * @default 2024-03-21T09:43:31.186946 */ time?: string; /** @@ -1064,7 +1062,7 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent - * @default 2024-03-20T16:40:56.509092 + * @default 2024-03-21T09:43:31.186946 */ time?: string; /** @@ -1107,6 +1105,21 @@ export interface components { /** Font */ font?: string | null; }; + /** PaginationSettings */ + PaginationSettings: { + /** + * Pg + * @description Page number + * @default 1 + */ + pg?: number; + /** + * Pgs + * @description Page size + * @default 10 + */ + pgs?: number; + }; /** PlainTextContentCreate */ PlainTextContentCreate: { /** @@ -1894,8 +1907,10 @@ export interface components { /** * @description General search settings * @default { - * "pg": 1, - * "pgs": 10, + * "pgn": { + * "pg": 1, + * "pgs": 10 + * }, * "strict": false * } */ diff --git a/Tekst-Web/src/views/SearchResultsView.vue b/Tekst-Web/src/views/SearchResultsView.vue index 53a12546..ece0f10a 100644 --- a/Tekst-Web/src/views/SearchResultsView.vue +++ b/Tekst-Web/src/views/SearchResultsView.vue @@ -74,8 +74,10 @@ async function execSearch(resetPage?: boolean) { ...searchReq.value, gen: { ...searchReq.value?.gen, - pg: pagination.value.page, - pgs: pagination.value.pageSize, + pgn: { + pg: pagination.value.page, + pgs: pagination.value.pageSize, + }, sort: sortingPreset.value, }, }, From 8317417ea420bee2cd87204dc10ba7dbc141f1c5 Mon Sep 17 00:00:00 2001 From: bkis Date: Fri, 22 Mar 2024 08:35:30 +0100 Subject: [PATCH 07/14] Delete user messages sent by user when they delete their account --- Tekst-API/tekst/auth.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tekst-API/tekst/auth.py b/Tekst-API/tekst/auth.py index 751707ac..ba752274 100644 --- a/Tekst-API/tekst/auth.py +++ b/Tekst-API/tekst/auth.py @@ -46,6 +46,7 @@ from tekst.email import TemplateIdentifier, broadcast_admin_notification, send_email from tekst.logging import log from tekst.models.content import ContentBaseDocument +from tekst.models.message import MessageDocument from tekst.models.resource import ResourceBaseDocument from tekst.models.user import UserCreate, UserDocument, UserRead, UserUpdate @@ -228,16 +229,19 @@ async def on_before_delete( ResourceBaseDocument.owner_id == user.id, with_children=True ).to_list() owned_resources_ids = [resource.id for resource in resources_docs] + # delete contents of owned resources await ContentBaseDocument.find( In(ContentBaseDocument.resource_id, owned_resources_ids), with_children=True, ).delete() + # delete owned resources await ResourceBaseDocument.find_one( In(ResourceBaseDocument.id, owned_resources_ids), with_children=True, ).delete() + # remove user ID from resource shares await ResourceBaseDocument.find( ResourceBaseDocument.shared_read == user.id, @@ -252,6 +256,9 @@ async def on_before_delete( Pull(ResourceBaseDocument.shared_write == user.id), ) + # delete user messages sent by user + await MessageDocument.find(MessageDocument.sender == user.id).delete() + async def on_after_delete(self, user: UserDocument, request: Request | None = None): send_email( user, From d6fec373e3164c93b906898b0a87838335013002 Mon Sep 17 00:00:00 2001 From: bkis Date: Fri, 22 Mar 2024 09:40:06 +0100 Subject: [PATCH 08/14] Implement server-side paginated, filtered user search for admin view --- Tekst-API/openapi.json | 216 +++++++++++++--- Tekst-API/tekst/models/user.py | 18 +- Tekst-API/tekst/routers/users.py | 132 ++++++++-- Tekst-Web/src/api/index.ts | 3 + Tekst-Web/src/api/schema.d.ts | 77 ++++-- Tekst-Web/src/composables/fetchers.ts | 37 ++- Tekst-Web/src/icons.ts | 2 + Tekst-Web/src/views/HelpView.vue | 1 + .../src/views/admin/AdminSystemUsersView.vue | 238 +++++++++--------- Tekst-Web/translations/ui/deDE.yml | 6 +- Tekst-Web/translations/ui/enUS.yml | 6 +- 11 files changed, 530 insertions(+), 206 deletions(-) diff --git a/Tekst-API/openapi.json b/Tekst-API/openapi.json index cb112a05..b70a803d 100644 --- a/Tekst-API/openapi.json +++ b/Tekst-API/openapi.json @@ -5076,52 +5076,169 @@ "tags": [ "users" ], - "summary": "Get users", - "operationId": "getUsers", + "summary": "Find users", + "operationId": "findUsers", + "security": [ + { + "APIKeyCookie": [] + }, + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 128, + "description": "Query string to search in user data", + "default": "", + "title": "Q" + }, + "description": "Query string to search in user data" + }, + { + "name": "active", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include active users", + "default": true, + "title": "Active" + }, + "description": "Include active users" + }, + { + "name": "inactive", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include inactive users", + "default": true, + "title": "Inactive" + }, + "description": "Include inactive users" + }, + { + "name": "verified", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include verified users", + "default": true, + "title": "Verified" + }, + "description": "Include verified users" + }, + { + "name": "unverified", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include unverified users", + "default": true, + "title": "Unverified" + }, + "description": "Include unverified users" + }, + { + "name": "admin", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include administrators", + "default": true, + "title": "Admin" + }, + "description": "Include administrators" + }, + { + "name": "user", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Include regular users", + "default": true, + "title": "User" + }, + "description": "Include regular users" + }, + { + "name": "pg", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Page number", + "default": 1, + "title": "Pg" + }, + "description": "Page number" + }, + { + "name": "pgs", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Page size", + "default": 10, + "title": "Pgs" + }, + "description": "Page size" + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/UserRead" - }, - "type": "array", - "title": "Response Get Users Users Get" + "$ref": "#/components/schemas/UsersSearchResult" } } } }, "401": { - "description": "Unauthorized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TekstErrorModel" } } - } + }, + "description": "Unauthorized" }, "403": { - "description": "Forbidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TekstErrorModel" } } - } - } - }, - "security": [ - { - "APIKeyCookie": [] + }, + "description": "Forbidden" }, - { - "OAuth2PasswordBearer": [] + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } - ] + } } }, "/users/public/{user}": { @@ -5209,15 +5326,10 @@ "in": "query", "required": false, "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], + "type": "string", + "maxLength": 128, "description": "Query string to search in user data", + "default": "", "title": "Q" }, "description": "Query string to search in user data" @@ -7203,7 +7315,7 @@ "format": "date-time", "title": "Time", "description": "Time when the message was sent", - "default": "2024-03-21T09:43:31.186946" + "default": "2024-03-22T08:31:07.924124" }, "read": { "type": "boolean", @@ -7270,7 +7382,7 @@ "format": "date-time", "title": "Time", "description": "Time when the message was sent", - "default": "2024-03-21T09:43:31.186946" + "default": "2024-03-22T08:31:07.924124" }, "read": { "type": "boolean", @@ -10724,24 +10836,29 @@ ], "title": "Bio" }, + "isActive": { + "type": "boolean", + "title": "Isactive" + }, "isSuperuser": { "type": "boolean", - "title": "Issuperuser", - "default": false + "title": "Issuperuser" }, "publicFields": { - "allOf": [ - { - "$ref": "#/components/schemas/MaybePrivateUserFields" - } - ], - "default": [] + "$ref": "#/components/schemas/MaybePrivateUserFields" } }, "type": "object", "required": [ "id", - "username" + "username", + "name", + "affiliation", + "avatarUrl", + "bio", + "isActive", + "isSuperuser", + "publicFields" ], "title": "UserReadPublic" }, @@ -10915,6 +11032,29 @@ "type": "object", "title": "UserUpdate" }, + "UsersSearchResult": { + "properties": { + "users": { + "items": { + "$ref": "#/components/schemas/UserRead" + }, + "type": "array", + "title": "Users", + "description": "Paginated users data" + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Total number of search hits" + } + }, + "type": "object", + "required": [ + "users", + "total" + ], + "title": "UsersSearchResult" + }, "ValidationError": { "properties": { "loc": { diff --git a/Tekst-API/tekst/models/user.py b/Tekst-API/tekst/models/user.py index 96ffabff..860e0ac6 100644 --- a/Tekst-API/tekst/models/user.py +++ b/Tekst-API/tekst/models/user.py @@ -69,12 +69,13 @@ class UserReadPublic(ModelBase): id: PydanticObjectId username: str - name: str | None = None - affiliation: str | None = None - avatar_url: str | None = None - bio: str | None = None - is_superuser: bool = False - public_fields: MaybePrivateUserFields = [] + name: str | None + affiliation: str | None + avatar_url: str | None + bio: str | None + is_active: bool + is_superuser: bool + public_fields: MaybePrivateUserFields @model_validator(mode="after") def model_postprocess(self): @@ -161,3 +162,8 @@ class UserCreate(User, schemas.BaseUserCreate): UserUpdate = User.update_model(schemas.BaseUserUpdate) + + +class UsersSearchResult(ModelBase): + users: Annotated[list[UserRead], Field(description="Paginated users data")] + total: Annotated[int, Field(description="Total number of search hits")] diff --git a/Tekst-API/tekst/routers/users.py b/Tekst-API/tekst/routers/users.py index 115dbcbf..28e9fb12 100644 --- a/Tekst-API/tekst/routers/users.py +++ b/Tekst-API/tekst/routers/users.py @@ -1,6 +1,6 @@ from typing import Annotated -from beanie.operators import Or, Text +from beanie.operators import NE, Eq, Or, Text from fastapi import APIRouter, Depends, Path, Query, Request, status from tekst import errors @@ -11,7 +11,12 @@ get_user_manager, ) from tekst.models.common import PydanticObjectId -from tekst.models.user import UserDocument, UserRead, UserReadPublic +from tekst.models.search import PaginationSettings +from tekst.models.user import ( + UserDocument, + UserReadPublic, + UsersSearchResult, +) from tekst.utils import validators as val @@ -42,7 +47,7 @@ async def delete_me( @router.get( "", - response_model=list[UserRead], + response_model=UsersSearchResult, status_code=status.HTTP_200_OK, responses=errors.responses( [ @@ -51,8 +56,105 @@ async def delete_me( ] ), ) -async def get_users(su: SuperuserDep) -> list[UserDocument]: - return await UserDocument.find_all().to_list() +async def find_users( + su: SuperuserDep, + query: Annotated[ + str, + Query( + alias="q", + description="Query string to search in user data", + max_length=128, + ), + ] = "", + is_active: Annotated[ + bool, + Query( + alias="active", + description="Include active users", + ), + ] = True, + is_inactive: Annotated[ + bool, + Query( + alias="inactive", + description="Include inactive users", + ), + ] = True, + is_verified: Annotated[ + bool, + Query( + alias="verified", + description="Include verified users", + ), + ] = True, + is_unverified: Annotated[ + bool, + Query( + alias="unverified", + description="Include unverified users", + ), + ] = True, + is_superuser: Annotated[ + bool, + Query( + alias="admin", + description="Include administrators", + ), + ] = True, + is_no_superuser: Annotated[ + bool, + Query( + alias="user", + description="Include regular users", + ), + ] = True, + page: Annotated[ + int, + Query( + alias="pg", + description="Page number", + ), + ] = 1, + page_size: Annotated[ + int, + Query( + alias="pgs", + description="Page size", + ), + ] = 10, +) -> UsersSearchResult: + # construct DB query + db_query = [ + Text(query) if query else {}, + Or( + Eq(UserDocument.is_active, is_active), + NE(UserDocument.is_active, is_inactive), + ), + Or( + Eq(UserDocument.is_verified, is_verified), + NE(UserDocument.is_verified, is_unverified), + ), + Or( + Eq(UserDocument.is_superuser, is_superuser), + NE(UserDocument.is_superuser, is_no_superuser), + ), + ] + + # count total possible hits + total = await UserDocument.find(*db_query).count() + + # return actual paginated, sorted restults + pgn = PaginationSettings(page=page, page_size=page_size) + return UsersSearchResult( + users=( + await UserDocument.find(*db_query) + .sort(+UserDocument.username) + .skip(pgn.mongo_skip()) + .limit(pgn.mongo_limit()) + .to_list() + ), + total=total, + ) @router.get( @@ -98,11 +200,14 @@ async def get_public_user( async def find_public_users( su: UserDep, query: Annotated[ - str | None, + str, val.CleanupOneline, - val.EmptyStringToNone, - Query(alias="q", description="Query string to search in user data"), - ] = None, + Query( + alias="q", + description="Query string to search in user data", + max_length=128, + ), + ] = "", ) -> list[UserDocument]: """ Returns a list of public users matching the given query. @@ -112,8 +217,7 @@ async def find_public_users( """ if not query: return [] - return [ - UserReadPublic(**user.model_dump()) - for user in await UserDocument.find(Text(query)).to_list() - if user.is_active - ] + return await UserDocument.find( + Text(query), + Eq(UserDocument.is_active, True), + ).to_list() diff --git a/Tekst-Web/src/api/index.ts b/Tekst-Web/src/api/index.ts index 99c27057..9f969a50 100644 --- a/Tekst-Web/src/api/index.ts +++ b/Tekst-Web/src/api/index.ts @@ -242,3 +242,6 @@ export type SortingPreset = components['schemas']['SortingPreset']; export type PlainTextSearchQuery = components['schemas']['PlainTextSearchQuery']; export type RichTextSearchQuery = components['schemas']['RichTextSearchQuery']; + +export type UserSearchFilters = NonNullable; +export type UsersSearchResult = components['schemas']['UsersSearchResult']; diff --git a/Tekst-Web/src/api/schema.d.ts b/Tekst-Web/src/api/schema.d.ts index 191e0b63..c9e5663f 100644 --- a/Tekst-Web/src/api/schema.d.ts +++ b/Tekst-Web/src/api/schema.d.ts @@ -315,8 +315,8 @@ export interface paths { patch: operations['users:patchCurrentUser']; }; '/users': { - /** Get users */ - get: operations['getUsers']; + /** Find users */ + get: operations['findUsers']; }; '/users/public/{user}': { /** @@ -1020,7 +1020,7 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent - * @default 2024-03-21T09:43:31.186946 + * @default 2024-03-22T08:31:07.924124 */ time?: string; /** @@ -1062,7 +1062,7 @@ export interface components { * Time * Format: date-time * @description Time when the message was sent - * @default 2024-03-21T09:43:31.186946 + * @default 2024-03-22T08:31:07.924124 */ time?: string; /** @@ -2835,20 +2835,18 @@ export interface components { /** Username */ username: string; /** Name */ - name?: string | null; + name: string | null; /** Affiliation */ - affiliation?: string | null; + affiliation: string | null; /** Avatarurl */ - avatarUrl?: string | null; + avatarUrl: string | null; /** Bio */ - bio?: string | null; - /** - * Issuperuser - * @default false - */ - isSuperuser?: boolean; - /** @default [] */ - publicFields?: components['schemas']['MaybePrivateUserFields']; + bio: string | null; + /** Isactive */ + isActive: boolean; + /** Issuperuser */ + isSuperuser: boolean; + publicFields: components['schemas']['MaybePrivateUserFields']; }; /** UserUpdate */ UserUpdate: { @@ -2894,6 +2892,19 @@ export interface components { */ adminNotificationTriggers?: components['schemas']['AdminNotificationTrigger'][]; }; + /** UsersSearchResult */ + UsersSearchResult: { + /** + * Users + * @description Paginated users data + */ + users: components['schemas']['UserRead'][]; + /** + * Total + * @description Total number of search hits + */ + total: number; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -5238,13 +5249,35 @@ export interface operations { }; }; }; - /** Get users */ - getUsers: { + /** Find users */ + findUsers: { + parameters: { + query?: { + /** @description Query string to search in user data */ + q?: string; + /** @description Include active users */ + active?: boolean; + /** @description Include inactive users */ + inactive?: boolean; + /** @description Include verified users */ + verified?: boolean; + /** @description Include unverified users */ + unverified?: boolean; + /** @description Include administrators */ + admin?: boolean; + /** @description Include regular users */ + user?: boolean; + /** @description Page number */ + pg?: number; + /** @description Page size */ + pgs?: number; + }; + }; responses: { /** @description Successful Response */ 200: { content: { - 'application/json': components['schemas']['UserRead'][]; + 'application/json': components['schemas']['UsersSearchResult']; }; }; /** @description Unauthorized */ @@ -5259,6 +5292,12 @@ export interface operations { 'application/json': components['schemas']['TekstErrorModel']; }; }; + /** @description Validation Error */ + 422: { + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; }; }; /** @@ -5304,7 +5343,7 @@ export interface operations { parameters: { query?: { /** @description Query string to search in user data */ - q?: string | null; + q?: string; }; }; responses: { diff --git a/Tekst-Web/src/composables/fetchers.ts b/Tekst-Web/src/composables/fetchers.ts index f486b8d5..e47f73bf 100644 --- a/Tekst-Web/src/composables/fetchers.ts +++ b/Tekst-Web/src/composables/fetchers.ts @@ -1,6 +1,6 @@ -import { ref, isRef, unref, watchEffect, type Ref } from 'vue'; +import { ref, isRef, unref, watchEffect, type Ref, watch } from 'vue'; import { GET } from '@/api'; -import type { UserReadPublic, PlatformStats, UserRead } from '@/api'; +import type { UserReadPublic, PlatformStats, UserRead, UserSearchFilters } from '@/api'; import { useDebounceFn } from '@vueuse/core'; import { STATIC_PATH } from '@/common'; import { useMessages } from './messages'; @@ -62,30 +62,45 @@ export function useStats() { return { stats, error, load }; } -export function useUsersAdmin() { - const users = ref | null>(null); +export function useUsersAdmin(filtersRef: Ref) { + const users = ref>([]); + const total = ref(0); const error = ref(false); const loading = ref(false); - async function load() { + async function load(filters: UserSearchFilters) { loading.value = true; - users.value = null; + users.value = []; error.value = false; - const { data, error: err } = await GET('/users', {}); + const { data, error: e } = await GET('/users', { + params: { + query: filters, + }, + }); - if (!err) { - users.value = data; + if (!e) { + users.value = data.users; + total.value = data.total; } else { error.value = true; } loading.value = false; } - load(); + const debouncedLoad = useDebounceFn(load, 500); + watch( + filtersRef, + (newFilters) => { + loading.value = true; + debouncedLoad(newFilters); + }, + { immediate: true, deep: true } + ); return { users, + total, loading, error, load, @@ -121,7 +136,7 @@ export function useUsersSearch(queryRef: Ref) { const debouncedLoad = useDebounceFn(load, 500); watchEffect(() => { loading.value = true; - debouncedLoad(unref(queryRef)); + debouncedLoad(queryRef.value); }); return { diff --git a/Tekst-Web/src/icons.ts b/Tekst-Web/src/icons.ts index 9d8b4e46..1d738a95 100644 --- a/Tekst-Web/src/icons.ts +++ b/Tekst-Web/src/icons.ts @@ -108,8 +108,10 @@ import MessageOutlined from '@vicons/material/MessageOutlined'; import MarkChatReadRound from '@vicons/material/MarkChatReadRound'; import MarkChatUnreadRound from '@vicons/material/MarkChatUnreadRound'; import SendFilled from '@vicons/material/SendFilled'; +import GroupsFilled from '@vicons/material/GroupsFilled'; export { + GroupsFilled as CommunityIcon, SendFilled as SendIcon, MarkChatReadRound as MarkChatReadIcon, MarkChatUnreadRound as MarkChatUnreadIcon, diff --git a/Tekst-Web/src/views/HelpView.vue b/Tekst-Web/src/views/HelpView.vue index 1acf2e6f..31045900 100644 --- a/Tekst-Web/src/views/HelpView.vue +++ b/Tekst-Web/src/views/HelpView.vue @@ -67,6 +67,7 @@ watch( +
{{ $t('help.msgFoundCount', { count: helpTextsFiltered?.length }) }}
diff --git a/Tekst-Web/src/views/admin/AdminSystemUsersView.vue b/Tekst-Web/src/views/admin/AdminSystemUsersView.vue index aacb3f74..fd82b7d3 100644 --- a/Tekst-Web/src/views/admin/AdminSystemUsersView.vue +++ b/Tekst-Web/src/views/admin/AdminSystemUsersView.vue @@ -1,5 +1,5 @@ @@ -197,67 +179,105 @@ onMounted(() => { - - -