mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
✨(frontend) add starred/important thread feature
The backend already scopes the starred flag per mailbox via ThreadAccess.starred_at. This commit exposes the feature in the frontend: a useStarred hook, a toggle button in the thread action bar, a starred badge in thread items, an important icon in the thread view subject, and a search filter checkbox for starred threads (is:starred / est:important). Also fixes the undo toast in use-flag to propagate mailboxId so that mailbox-scoped flags (unread, starred) can be properly undone.
This commit is contained in:
@@ -5652,13 +5652,19 @@
|
||||
"mailbox_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Mailbox UUID. Required when flag is 'unread'."
|
||||
"description": "Mailbox UUID. Required when flag is 'unread' or 'starred'."
|
||||
},
|
||||
"read_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "Timestamp up to which messages are considered read. When provided with flag='unread', sets ThreadAccess.read_at directly. null means nothing has been read (all messages unread)."
|
||||
},
|
||||
"starred_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "Timestamp when the thread was starred. When provided with flag='starred' and value=true, sets ThreadAccess.starred_at. null or value=false removes the starred flag."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7024,10 +7030,6 @@
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
},
|
||||
"is_starred": {
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
},
|
||||
"is_trashed": {
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
@@ -7068,7 +7070,6 @@
|
||||
"is_archived",
|
||||
"is_draft",
|
||||
"is_sender",
|
||||
"is_starred",
|
||||
"is_trashed",
|
||||
"is_unread",
|
||||
"parent_id",
|
||||
@@ -8123,13 +8124,20 @@
|
||||
"format": "date-time",
|
||||
"readOnly": true,
|
||||
"nullable": true
|
||||
},
|
||||
"starred_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"readOnly": true,
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"mailbox",
|
||||
"read_at",
|
||||
"role"
|
||||
"role",
|
||||
"starred_at"
|
||||
]
|
||||
},
|
||||
"ThreadAccessRequest": {
|
||||
|
||||
@@ -194,7 +194,7 @@ class ChangeFlagView(APIView):
|
||||
).values_list("thread_id", flat=True)
|
||||
|
||||
# Unread and starred are personal actions that don't require EDITOR access.
|
||||
if flag not in ('unread', 'starred'):
|
||||
if flag not in ("unread", "starred"):
|
||||
accessible_thread_ids_qs = accessible_thread_ids_qs.filter(
|
||||
role__in=enums.THREAD_ROLES_CAN_EDIT
|
||||
)
|
||||
@@ -204,19 +204,19 @@ class ChangeFlagView(APIView):
|
||||
mailbox_id=mailbox_id
|
||||
)
|
||||
|
||||
if flag in ("unread", "starred") and not thread_ids and message_ids:
|
||||
# If no thread_ids but we have message_ids, we need to get the thread_ids from the messages
|
||||
thread_ids = (
|
||||
models.Message.objects.filter(
|
||||
id__in=message_ids,
|
||||
thread_id__in=accessible_thread_ids_qs,
|
||||
)
|
||||
.values_list("thread_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
if flag == "unread":
|
||||
if not thread_ids and message_ids:
|
||||
# If no thread_ids but we have message_ids, we need to get the thread_ids from the messages
|
||||
thread_ids = (
|
||||
models.Message.objects.filter(
|
||||
id__in=message_ids,
|
||||
thread_id__in=accessible_thread_ids_qs,
|
||||
)
|
||||
.values_list("thread_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return self._handle_unread_flag(
|
||||
request,
|
||||
thread_ids,
|
||||
@@ -365,7 +365,11 @@ class ChangeFlagView(APIView):
|
||||
updated_count = accesses.update(read_at=read_at)
|
||||
|
||||
if thread_ids_to_sync:
|
||||
update_threads_mailbox_flags_task.delay(thread_ids_to_sync)
|
||||
transaction.on_commit(
|
||||
lambda ids=thread_ids_to_sync: update_threads_mailbox_flags_task.delay(
|
||||
ids
|
||||
)
|
||||
)
|
||||
|
||||
return drf.response.Response(
|
||||
{"success": True, "updated_threads": updated_count}
|
||||
@@ -412,14 +416,17 @@ class ChangeFlagView(APIView):
|
||||
mailbox_id=mailbox_id,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
thread_ids_to_sync = [
|
||||
str(tid) for tid in accesses.values_list("thread_id", flat=True)
|
||||
]
|
||||
updated_count = accesses.update(starred_at=starred_at)
|
||||
thread_ids_to_sync = [
|
||||
str(tid) for tid in accesses.values_list("thread_id", flat=True)
|
||||
]
|
||||
updated_count = accesses.update(starred_at=starred_at)
|
||||
|
||||
if thread_ids_to_sync:
|
||||
update_threads_mailbox_flags_task.delay(thread_ids_to_sync)
|
||||
transaction.on_commit(
|
||||
lambda ids=thread_ids_to_sync: update_threads_mailbox_flags_task.delay(
|
||||
ids
|
||||
)
|
||||
)
|
||||
|
||||
return drf.response.Response(
|
||||
{"success": True, "updated_threads": updated_count}
|
||||
|
||||
@@ -321,6 +321,7 @@ def test_api_flag_unread_per_mailbox_isolation(api_client):
|
||||
assert access2.read_at is None
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_flag_read_at_syncs_opensearch(api_client, settings):
|
||||
"""Sending read_at should explicitly sync OpenSearch."""
|
||||
settings.OPENSEARCH_INDEX_THREADS = True
|
||||
@@ -534,6 +535,7 @@ def test_api_flag_starred_requires_mailbox_id(api_client):
|
||||
assert "mailbox_id" in response.data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@patch("core.api.viewsets.flag.update_threads_mailbox_flags_task")
|
||||
def test_api_flag_mark_thread_starred_success(mock_task, api_client):
|
||||
"""Test starring a thread sets starred_at on the ThreadAccess."""
|
||||
@@ -564,6 +566,7 @@ def test_api_flag_mark_thread_starred_success(mock_task, api_client):
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@patch("core.api.viewsets.flag.update_threads_mailbox_flags_task")
|
||||
def test_api_flag_mark_thread_unstarred_success(mock_task, api_client):
|
||||
"""Test unstarring a thread clears starred_at on the ThreadAccess."""
|
||||
@@ -624,6 +627,32 @@ def test_api_flag_starred_scoped_to_mailbox(api_client):
|
||||
assert access_b.starred_at is None
|
||||
|
||||
|
||||
def test_api_flag_starred_no_permission_on_mailbox(api_client):
|
||||
"""Test that starring via a mailbox the user doesn't have access to does nothing."""
|
||||
user = UserFactory()
|
||||
api_client.force_authenticate(user=user)
|
||||
other_mailbox = MailboxFactory() # User does not have access
|
||||
thread = ThreadFactory()
|
||||
access = ThreadAccessFactory(
|
||||
mailbox=other_mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR
|
||||
)
|
||||
MessageFactory(thread=thread)
|
||||
|
||||
data = {
|
||||
"flag": "starred",
|
||||
"value": True,
|
||||
"thread_ids": [str(thread.id)],
|
||||
"mailbox_id": str(other_mailbox.id),
|
||||
}
|
||||
response = api_client.post(API_URL, data=data, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["updated_threads"] == 0
|
||||
|
||||
access.refresh_from_db()
|
||||
assert access.starred_at is None
|
||||
|
||||
|
||||
def test_api_flag_viewer_can_star_thread(api_client):
|
||||
"""Test that a VIEWER can star a thread (personal action)."""
|
||||
user = UserFactory()
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"{{count}} hours ago_other": "{{count}} hours ago",
|
||||
"{{count}} messages_one": "{{count}} message",
|
||||
"{{count}} messages_other": "{{count}} messages",
|
||||
"{{count}} messages are now starred._one": "The message is now starred.",
|
||||
"{{count}} messages are now starred._other": "{{count}} messages are now starred.",
|
||||
"{{count}} messages have been archived._one": "The message has been archived.",
|
||||
"{{count}} messages have been archived._other": "{{count}} messages have been archived.",
|
||||
"{{count}} messages have been deleted._one": "The message has been deleted.",
|
||||
@@ -31,6 +33,8 @@
|
||||
"{{count}} results_other": "{{count}} results",
|
||||
"{{count}} selected threads_one": "{{count}} selected thread",
|
||||
"{{count}} selected threads_other": "{{count}} selected threads",
|
||||
"{{count}} threads are now starred._one": "The thread is now starred.",
|
||||
"{{count}} threads are now starred._other": "{{count}} threads are now starred.",
|
||||
"{{count}} threads have been archived._one": "The thread has been archived.",
|
||||
"{{count}} threads have been archived._other": "{{count}} threads have been archived.",
|
||||
"{{count}} threads have been deleted._one": "The thread has been deleted.",
|
||||
@@ -321,7 +325,6 @@
|
||||
"Mark {{count}} threads as unread_other": "Mark {{count}} threads as unread",
|
||||
"Mark all as read": "Mark all as read",
|
||||
"Mark all as unread": "Mark all as unread",
|
||||
"Mark as important": "Mark as important",
|
||||
"Mark as read": "Mark as read",
|
||||
"Mark as read from here": "Mark as read from here",
|
||||
"Mark as unread": "Mark as unread",
|
||||
@@ -447,6 +450,11 @@
|
||||
"Spam": "Spam",
|
||||
"Spam report removed from {{count}} threads._one": "Spam report removed from the thread.",
|
||||
"Spam report removed from {{count}} threads._other": "Spam report removed from {{count}} threads.",
|
||||
"Star {{count}} threads_one": "Star {{count}} thread",
|
||||
"Star {{count}} threads_other": "Star {{count}} threads",
|
||||
"Star from here": "Star from here",
|
||||
"Star this thread": "Star this thread",
|
||||
"Starred": "Starred",
|
||||
"Start typing...": "Start typing...",
|
||||
"Subject": "Subject",
|
||||
"Subject template": "Subject template",
|
||||
@@ -498,6 +506,7 @@
|
||||
"This signature is forced": "This signature is forced",
|
||||
"This thread has been reported as spam.": "This thread has been reported as spam.",
|
||||
"This thread has been reported as spam. For your security, downloading attachments has been disabled.": "This thread has been reported as spam. For your security, downloading attachments has been disabled.",
|
||||
"This thread has been starred.": "This thread has been starred.",
|
||||
"Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.",
|
||||
"Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.",
|
||||
"Thread access removed": "Thread access removed",
|
||||
@@ -524,6 +533,9 @@
|
||||
"Unknown user": "Unknown user",
|
||||
"Unread": "Unread",
|
||||
"Unsaved changes": "Unsaved changes",
|
||||
"Unstar {{count}} threads_one": "Unstar {{count}} thread",
|
||||
"Unstar {{count}} threads_other": "Unstar {{count}} threads",
|
||||
"Unstar this thread": "Unstar this thread",
|
||||
"until {{date}}": "until {{date}}",
|
||||
"Update": "Update",
|
||||
"Update a Label": "Update a Label",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"{{count}} messages_one": "{{count}} message",
|
||||
"{{count}} messages_many": "{{count}} messages",
|
||||
"{{count}} messages_other": "{{count}} messages",
|
||||
"{{count}} messages are now starred._one": "{{count}} message est maintenant marqué pour suivi.",
|
||||
"{{count}} messages are now starred._many": "{{count}} messages sont maintenant marqués pour suivi.",
|
||||
"{{count}} messages are now starred._other": "{{count}} messages sont maintenant marqués pour suivi.",
|
||||
"{{count}} messages have been archived._one": "Le message a été archivé.",
|
||||
"{{count}} messages have been archived._many": "{{count}} messages ont été archivés.",
|
||||
"{{count}} messages have been archived._other": "{{count}} messages ont été archivés.",
|
||||
@@ -47,6 +50,9 @@
|
||||
"{{count}} selected threads_one": "{{count}} conversation sélectionnée",
|
||||
"{{count}} selected threads_many": "{{count}} conversations sélectionnées",
|
||||
"{{count}} selected threads_other": "{{count}} conversations sélectionnées",
|
||||
"{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.",
|
||||
"{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.",
|
||||
"{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.",
|
||||
"{{count}} threads have been archived._one": "La conversation a été archivée.",
|
||||
"{{count}} threads have been archived._many": "{{count}} conversations ont été archivées.",
|
||||
"{{count}} threads have been archived._other": "{{count}} conversations ont été archivées.",
|
||||
@@ -356,7 +362,6 @@
|
||||
"Mark {{count}} threads as unread_other": "Marquer {{count}} conversations comme non lues",
|
||||
"Mark all as read": "Tout marquer comme lu",
|
||||
"Mark all as unread": "Tout marquer comme non lu",
|
||||
"Mark as important": "Marquer comme important",
|
||||
"Mark as read": "Marquer comme lu",
|
||||
"Mark as read from here": "Marquer comme lu à partir d'ici",
|
||||
"Mark as unread": "Marquer comme non lu",
|
||||
@@ -487,6 +492,12 @@
|
||||
"Spam report removed from {{count}} threads._one": "Le signalement spam a été annulé.",
|
||||
"Spam report removed from {{count}} threads._many": "{{count}} signalements spam ont été annulés.",
|
||||
"Spam report removed from {{count}} threads._other": "{{count}} signalements spam ont été annulés.",
|
||||
"Star {{count}} threads_one": "Suivre {{count}} conversation",
|
||||
"Star {{count}} threads_many": "Suivre {{count}} conversations",
|
||||
"Star {{count}} threads_other": "Suivre {{count}} conversations",
|
||||
"Star from here": "Suivre à partir d'ici",
|
||||
"Star this thread": "Suivre cette conversation",
|
||||
"Starred": "Suivi",
|
||||
"Start typing...": "Commencez à écrire...",
|
||||
"Subject": "Objet",
|
||||
"Subject template": "Modèle d'objet",
|
||||
@@ -539,6 +550,7 @@
|
||||
"This signature is forced": "Cette signature est forcée",
|
||||
"This thread has been reported as spam.": "Cette conversation a été signalée comme spam.",
|
||||
"This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Cette conversation a été signalée comme spam. Pour votre sécurité, le téléchargement des pièces jointes a été désactivé.",
|
||||
"This thread has been starred.": "Cette conversation a été marquée pour suivi.",
|
||||
"Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces modèles de message sont liés à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.",
|
||||
"Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces signatures sont liées à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.",
|
||||
"Thread access removed": "Accès à la conversation supprimé",
|
||||
@@ -567,6 +579,10 @@
|
||||
"Unknown user": "Utilisateur inconnu",
|
||||
"Unread": "Non lu",
|
||||
"Unsaved changes": "Modifications non enregistrées",
|
||||
"Unstar {{count}} threads_one": "Ne plus suivre {{count}} conversation",
|
||||
"Unstar {{count}} threads_many": "Ne plus suivre {{count}} conversations",
|
||||
"Unstar {{count}} threads_other": "Ne plus suivre {{count}} conversations",
|
||||
"Unstar this thread": "Ne plus suivre cette conversation",
|
||||
"until {{date}}": "jusqu'au {{date}}",
|
||||
"Update": "Mettre à jour",
|
||||
"Update a Label": "Modifier un libellé",
|
||||
|
||||
@@ -14,11 +14,16 @@ export interface ChangeFlagRequestRequest {
|
||||
message_ids?: string[];
|
||||
/** List of thread UUIDs where all messages should have the flag change applied. */
|
||||
thread_ids?: string[];
|
||||
/** Mailbox UUID. Required when flag is 'unread'. */
|
||||
/** Mailbox UUID. Required when flag is 'unread' or 'starred'. */
|
||||
mailbox_id?: string;
|
||||
/**
|
||||
* Timestamp up to which messages are considered read. When provided with flag='unread', sets ThreadAccess.read_at directly. null means nothing has been read (all messages unread).
|
||||
* @nullable
|
||||
*/
|
||||
read_at?: string | null;
|
||||
/**
|
||||
* Timestamp when the thread was starred. When provided with flag='starred' and value=true, sets ThreadAccess.starred_at. null or value=false removes the starred flag.
|
||||
* @nullable
|
||||
*/
|
||||
starred_at?: string | null;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface Message {
|
||||
readonly is_sender: boolean;
|
||||
readonly is_draft: boolean;
|
||||
readonly is_unread: boolean;
|
||||
readonly is_starred: boolean;
|
||||
readonly is_trashed: boolean;
|
||||
readonly is_archived: boolean;
|
||||
readonly has_attachments: boolean;
|
||||
|
||||
@@ -18,4 +18,6 @@ export interface ThreadAccessDetail {
|
||||
readonly role: ThreadAccessRoleChoices;
|
||||
/** @nullable */
|
||||
readonly read_at: string | null;
|
||||
/** @nullable */
|
||||
readonly starred_at: string | null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/com
|
||||
import { SearchHelper } from "@/features/utils/search-helper";
|
||||
import { Label } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, Checkbox, Input, Select } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useRef } from "react";
|
||||
import { useId, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SearchFiltersFormProps = {
|
||||
@@ -12,6 +12,7 @@ type SearchFiltersFormProps = {
|
||||
|
||||
export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const starredLabelId = useId();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const updateQuery = (submit: boolean) => {
|
||||
@@ -86,6 +87,10 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) =
|
||||
<Checkbox label={t("Read")} value="true" name="is_read" checked={Boolean(parsedQuery.is_read)} onChange={handleReadStateChange} />
|
||||
<Checkbox label={t("Unread")} value="true" name="is_unread" checked={Boolean(parsedQuery.is_unread)} onChange={handleReadStateChange} />
|
||||
</div>
|
||||
<div className="flex-row flex-align-center" style={{ gap: 'var(--c--globals--spacings--2xs)' }}>
|
||||
<Label htmlFor="is_starred" id={starredLabelId}>{t("Starred")} :</Label>
|
||||
<Checkbox id="is_starred" aria-labelledby={starredLabelId} value="true" name="is_starred" checked={Boolean(parsedQuery.is_starred)} />
|
||||
</div>
|
||||
<footer className="search__filters-footer">
|
||||
<Button type="reset" variant="tertiary" onClick={handleReset}>
|
||||
{t("Reset")}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"in_drafts": ["in:drafts"],
|
||||
"in_spam": ["in:spam"],
|
||||
"is_read": ["is:read"],
|
||||
"is_unread": ["is:unread"]
|
||||
"is_unread": ["is:unread"],
|
||||
"is_starred": ["is:starred"]
|
||||
},
|
||||
"fr-FR": {
|
||||
"from": ["de"],
|
||||
@@ -23,7 +24,8 @@
|
||||
"in_drafts": ["dans:brouillons"],
|
||||
"in_spam": ["dans:spam"],
|
||||
"is_read": ["est:lu"],
|
||||
"is_unread": ["est:nonlu"]
|
||||
"is_unread": ["est:nonlu"],
|
||||
"is_starred": ["est:suivi"]
|
||||
},
|
||||
"nl-NL": {
|
||||
"from": ["van"],
|
||||
@@ -36,6 +38,7 @@
|
||||
"in_drafts": ["in:concepten"],
|
||||
"in_spam": ["in:spam"],
|
||||
"is_read": ["is:gelezen"],
|
||||
"is_unread": ["is:ongelezen"]
|
||||
"is_unread": ["is:ongelezen"],
|
||||
"is_starred": ["is:gevolgd"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
|
||||
.thread-item__column--metadata {
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
|
||||
.thread-item__label-bullets {
|
||||
|
||||
@@ -154,19 +154,6 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
)}
|
||||
</div>
|
||||
<div className="thread-item__column thread-item__column--metadata">
|
||||
{/* <Tooltip content={thread.labels.map((label) => label.display_name).join(', ')}>
|
||||
<div className="thread-item__label-bullets">
|
||||
{thread.labels.slice(0, 4).map((label) => (
|
||||
<div key={`label-bullet-${label.id}`} className="thread-item__label-bullet" style={{ backgroundColor: label.color }} />
|
||||
))}
|
||||
{thread.labels.length > 4 && (
|
||||
<div className="thread-item__label-bullet">
|
||||
+{thread.labels.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip> */}
|
||||
|
||||
{(threadDate || thread.messaged_at) && (
|
||||
<span className="thread-item__date">
|
||||
{DateHelper.formatDate((threadDate || thread.messaged_at)!, i18n.resolvedLanguage)}
|
||||
@@ -176,7 +163,7 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
</div>
|
||||
<div className="thread-item__row thread-item__row--subject">
|
||||
<div className="thread-item__column">
|
||||
<p className="thread-item__subject">{thread.subject || thread.snippet || t('No subject')}</p>
|
||||
<p className="thread-item__subject">{thread.subject || t('No subject')}</p>
|
||||
</div>
|
||||
<div className="thread-item__column thread-item__column--badges">
|
||||
{thread.has_draft && (
|
||||
@@ -203,15 +190,11 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
<Icon name="update" type={IconType.OUTLINED} size={IconSize.SMALL} />
|
||||
</Badge>
|
||||
)}
|
||||
{/* <div className="thread-item__actions">
|
||||
<Tooltip placement="bottom" content={t('Mark as important')}>
|
||||
<Button color="tertiary-text" className="thread-item__action">
|
||||
<span className="material-icons">
|
||||
flag
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div> */}
|
||||
{thread.has_starred && (
|
||||
<Badge aria-label={t('Starred')} title={t('Starred')} color="yellow" variant="tertiary" compact>
|
||||
<Icon name="star" size={IconSize.SMALL} />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-item__row">
|
||||
|
||||
@@ -11,6 +11,7 @@ import ViewHelper from "@/features/utils/view-helper";
|
||||
import useArchive from "@/features/message/use-archive";
|
||||
import useSpam from "@/features/message/use-spam";
|
||||
import useTrash from "@/features/message/use-trash";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
|
||||
type ThreadPanelTitleProps = {
|
||||
selectedThreadIds: Set<string>;
|
||||
@@ -29,6 +30,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
const { markAsArchived, markAsUnarchived } = useArchive();
|
||||
const { markAsTrashed, markAsUntrashed } = useTrash();
|
||||
const { markAsSpam, markAsNotSpam } = useSpam();
|
||||
const { markAsStarred, markAsUnstarred } = useStarred();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const isSearch = searchParams.has('search');
|
||||
@@ -87,6 +89,9 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
const trashIconName = isTrashedView ? 'restore_from_trash' : 'delete';
|
||||
const trashMutation = isTrashedView ? markAsUntrashed : markAsTrashed;
|
||||
|
||||
const starLabel = t('Star {{count}} threads', { count: selectedThreadIds.size, defaultValue_one: 'Star {{count}} thread' });
|
||||
const unstarLabel = t('Unstar {{count}} threads', { count: selectedThreadIds.size, defaultValue_one: 'Unstar {{count}} thread' });
|
||||
|
||||
return (
|
||||
<header className="thread-panel__header">
|
||||
<h2 className="thread-panel__header--title">{title}</h2>
|
||||
@@ -127,6 +132,24 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
</Tooltip>
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<Tooltip content={starLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
markAsStarred({
|
||||
threadIds: threadIdsToMark,
|
||||
onSuccess: () => {
|
||||
unselectThread();
|
||||
onClearSelection();
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={selectedThreadIds.size === 0}
|
||||
icon={<Icon name="star_border" type={IconType.OUTLINED} />}
|
||||
variant="tertiary"
|
||||
size="nano"
|
||||
aria-label={starLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
<VerticalSeparator withPadding={false} />
|
||||
{!isSpamView && !isTrashedView && !isDraftsView && (
|
||||
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
@@ -223,6 +246,19 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
})
|
||||
},
|
||||
},
|
||||
...(isSelectionMode && selectedThreadIds.size > 0 ? [{
|
||||
label: unstarLabel,
|
||||
icon: <Icon name="star" type={IconType.FILLED} />,
|
||||
callback: () => {
|
||||
markAsUnstarred({
|
||||
threadIds: threadIdsToMark,
|
||||
onSuccess: () => {
|
||||
unselectThread();
|
||||
onClearSelection();
|
||||
}
|
||||
});
|
||||
},
|
||||
}] : []),
|
||||
]}
|
||||
>
|
||||
<Tooltip content={t('More options')}>
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
.thread-view__subject {
|
||||
font-weight: 700;
|
||||
font-size: var(--c--globals--font--sizes--h4);
|
||||
margin-top: var(--c--globals--spacings--xs);
|
||||
vertical-align: baseline;
|
||||
|
||||
& > .thread-view__subject__star {
|
||||
font-size: var(--c--globals--font--sizes--h4);
|
||||
color: var(--c--contextuals--background--palette--yellow--secondary);
|
||||
transform: translate(-4px, 3px)
|
||||
}
|
||||
}
|
||||
|
||||
.thread-view__sticky-container {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
.starred-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
padding-block: var(--c--globals--spacings--xs);
|
||||
padding-right: var(--c--globals--spacings--sm);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.starred-marker::after {
|
||||
display: block;
|
||||
content: '';
|
||||
height: 1px;
|
||||
background-color: var(--c--contextuals--background--semantic--neutral--primary);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.starred-marker__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--3xs);
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
color: var(--c--contextuals--background--semantic--neutral--primary);
|
||||
font-style: italic;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.starred-marker__icon {
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
color: var(--c--contextuals--background--semantic--neutral--primary);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"
|
||||
|
||||
export const StarredMarker = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="starred-marker">
|
||||
<span className="starred-marker__label">
|
||||
<Icon name="star" type={IconType.FILLED} className="starred-marker__icon" />
|
||||
{t("This thread has been starred.")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ThreadAccessesWidget } from "../thread-accesses-widget";
|
||||
import { ThreadLabelsWidget } from "../thread-labels-widget";
|
||||
import useArchive from "@/features/message/use-archive";
|
||||
import useSpam from "@/features/message/use-spam";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
|
||||
type ThreadActionBarProps = {
|
||||
canUndelete: boolean;
|
||||
@@ -22,7 +23,9 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
const { markAsTrashed, markAsUntrashed } = useTrash();
|
||||
const { markAsArchived, markAsUnarchived } = useArchive();
|
||||
const { markAsSpam, markAsNotSpam } = useSpam();
|
||||
const { markAsStarred, markAsUnstarred } = useStarred();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const isStarred = selectedThread?.has_starred;
|
||||
|
||||
return (
|
||||
<div className="thread-action-bar">
|
||||
@@ -110,6 +113,27 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
)
|
||||
}
|
||||
<VerticalSeparator />
|
||||
{isStarred ? (
|
||||
<Tooltip content={t('Unstar this thread')}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
aria-label={t('Unstar this thread')}
|
||||
size="nano"
|
||||
icon={<Icon name="star" />}
|
||||
onClick={() => markAsUnstarred({ threadIds: [selectedThread!.id] })}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip content={t('Star this thread')}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
aria-label={t('Star this thread')}
|
||||
size="nano"
|
||||
icon={<Icon name="star_border" />}
|
||||
onClick={() => markAsStarred({ threadIds: [selectedThread!.id] })}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ThreadLabelsWidget threadId={selectedThread!.id} selectedLabels={selectedThread!.labels} />
|
||||
<ThreadAccessesWidget accesses={selectedThread!.accesses} />
|
||||
<DropdownMenu
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRequestUrl } from "@/features/api/utils";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import usePrint from "@/features/message/use-print";
|
||||
import useRead from "@/features/message/use-read";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
import useTrash from "@/features/message/use-trash";
|
||||
import { ThreadMessageActionsProps } from "./types";
|
||||
|
||||
@@ -23,8 +24,9 @@ const ThreadMessageActions = ({
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
// Hooks and state specific to actions
|
||||
const { unselectThread, selectedThread } = useMailboxContext();
|
||||
const { unselectThread, selectedThread, selectedMailbox } = useMailboxContext();
|
||||
const { markAsReadAt } = useRead();
|
||||
const { markAsStarred } = useStarred();
|
||||
const { markAsTrashed } = useTrash();
|
||||
const { print } = usePrint();
|
||||
|
||||
@@ -46,6 +48,14 @@ const ThreadMessageActions = ({
|
||||
}
|
||||
}, [message.id, message.created_at, unselectThread, selectedThread, markAsReadAt]);
|
||||
|
||||
const starredAt = selectedThread?.accesses.find(a => a.mailbox.id === selectedMailbox?.id)?.starred_at ?? null;
|
||||
const isAlreadyStarredFromHere = starredAt === message.created_at;
|
||||
|
||||
const handleStarredFrom = useCallback(() => {
|
||||
if (!selectedThread) return;
|
||||
markAsStarred({ threadIds: [selectedThread.id], starredAt: message.created_at! });
|
||||
}, [selectedThread, message.created_at, markAsStarred]);
|
||||
|
||||
const handleMarkAsTrashed = useCallback(() => {
|
||||
markAsTrashed({ messageIds: [message.id] });
|
||||
}, [markAsTrashed, message.id]);
|
||||
@@ -82,6 +92,11 @@ const ThreadMessageActions = ({
|
||||
icon: <Icon type={IconType.FILLED} name="mark_email_unread" />,
|
||||
callback: () => toggleReadStateFrom(true)
|
||||
}]),
|
||||
...(!isAlreadyStarredFromHere && hasSiblingMessages ? [{
|
||||
label: t('Star from here'),
|
||||
icon: <Icon type={IconType.FILLED} name="star_border" />,
|
||||
callback: handleStarredFrom,
|
||||
}] : []),
|
||||
{
|
||||
label: t('Print'),
|
||||
icon: <Icon type={IconType.FILLED} name="print" />,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
|
||||
import { ThreadActionBar } from "./components/thread-action-bar"
|
||||
import { ThreadMessage } from "./components/thread-message"
|
||||
@@ -12,6 +12,7 @@ import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ThreadViewLabelsList } from "./components/thread-view-labels-list"
|
||||
import { ThreadSummary } from "./components/thread-summary";
|
||||
import { StarredMarker } from "./components/starred-marker";
|
||||
import clsx from "clsx";
|
||||
import ThreadViewProvider, { useThreadViewContext } from "./provider";
|
||||
import useSpam from "@/features/message/use-spam";
|
||||
@@ -57,6 +58,18 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages,
|
||||
}
|
||||
return acc;
|
||||
}, messages[0]);
|
||||
const starredAt = thread.accesses.find(a => a.mailbox.id === mailboxId)?.starred_at ?? null;
|
||||
const starredMarkerInsertIndex = useMemo(() => {
|
||||
// Find insertion index: marker goes after the last message whose created_at <= starred_at
|
||||
let starredInsertIndex = null;
|
||||
if (starredAt) {
|
||||
starredInsertIndex = messages.findLastIndex((message) => {
|
||||
return message.created_at <= starredAt
|
||||
});
|
||||
return starredInsertIndex;
|
||||
}
|
||||
return starredInsertIndex;
|
||||
}, [starredAt, messages]);
|
||||
|
||||
/**
|
||||
* Setup an intersection observer to mark messages as read when they are
|
||||
@@ -125,7 +138,12 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages,
|
||||
<header className="thread-view__header">
|
||||
<div className="thread-view__header__top">
|
||||
<ThreadActionBar canUndelete={isThreadTrashed} canUnarchive={isThreadArchived} />
|
||||
<h2 className="thread-view__subject">{thread.subject || t('No subject')}</h2>
|
||||
<h2 className="thread-view__subject">
|
||||
{thread.has_starred &&
|
||||
<Icon name="star" type={IconType.FILLED} className="thread-view__subject__star" aria-label={t('Starred')} />
|
||||
}
|
||||
{thread.subject || t('No subject')}
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
@@ -167,21 +185,30 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages,
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
{messages.map((message) => {
|
||||
const isLatest = latestMessage?.id === message.id;
|
||||
const isUnread = message.is_unread;
|
||||
return (
|
||||
<ThreadMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLatest={isLatest}
|
||||
ref={isUnread ? (el => { unreadRefs.current[message.id] = el; }) : undefined}
|
||||
data-message-id={message.id}
|
||||
data-created-at={message.created_at}
|
||||
draftMessage={message.draft_message}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
return messages.map((message, index) => {
|
||||
const isLatest = latestMessage?.id === message.id;
|
||||
const isUnread = message.is_unread;
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{starredMarkerInsertIndex === -1 && index === 0 && (
|
||||
<StarredMarker />
|
||||
)}
|
||||
<ThreadMessage
|
||||
message={message}
|
||||
isLatest={isLatest}
|
||||
ref={isUnread ? (el => { unreadRefs.current[message.id] = el; }) : undefined}
|
||||
data-message-id={message.id}
|
||||
data-created-at={message.created_at}
|
||||
draftMessage={message.draft_message}
|
||||
/>
|
||||
{starredMarkerInsertIndex === index && (
|
||||
<StarredMarker />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFlagCreate } from "@/features/api/gen"
|
||||
import { Thread, Message, FlagEnum, ChangeFlagRequestRequest } from "@/features/api/gen/models"
|
||||
import { Thread, Message, FlagEnum, ChangeFlagRequestRequest, Mailbox } from "@/features/api/gen/models"
|
||||
import { addToast, ToasterItem } from "../ui/components/toaster";
|
||||
import { toast } from "react-toastify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -9,6 +9,7 @@ type MarkAsOptions = {
|
||||
messageIds?: Message['id'][],
|
||||
mailboxId?: string,
|
||||
readAt?: string | null,
|
||||
starredAt?: string | null,
|
||||
onSuccess?: (data: ChangeFlagRequestRequest) => void,
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
|
||||
flag={flag}
|
||||
threadIds={data.thread_ids}
|
||||
messageIds={data.message_ids}
|
||||
mailboxId={data.mailbox_id}
|
||||
toastId={toastId}
|
||||
messages={options?.toastMessages}
|
||||
onUndo={options?.onSuccess}
|
||||
@@ -50,7 +52,7 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
|
||||
|
||||
const markAs =
|
||||
(status: boolean) =>
|
||||
({ threadIds = [], messageIds = [], mailboxId, readAt, onSuccess }: MarkAsOptions) =>
|
||||
({ threadIds = [], messageIds = [], mailboxId, readAt, starredAt, onSuccess }: MarkAsOptions) =>
|
||||
mutate({
|
||||
data: {
|
||||
flag,
|
||||
@@ -59,6 +61,7 @@ const useFlag = (flag: FlagEnum, options?: FlagOptions) => {
|
||||
message_ids: messageIds,
|
||||
mailbox_id: mailboxId,
|
||||
...(readAt !== undefined && { read_at: readAt }),
|
||||
...(starredAt !== undefined && { starred_at: starredAt }),
|
||||
},
|
||||
}, {
|
||||
onSuccess: (_, { data }) => onSuccess?.(data)
|
||||
@@ -75,11 +78,12 @@ type FlagUpdateSuccessToastProps = {
|
||||
flag: FlagEnum;
|
||||
threadIds?: Thread['id'][];
|
||||
messageIds?: Message['id'][];
|
||||
mailboxId?: Mailbox['id'];
|
||||
toastId: string;
|
||||
messages?: FlagToastMessages;
|
||||
onUndo?: (data: ChangeFlagRequestRequest) => void;
|
||||
}
|
||||
const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], toastId, messages, onUndo }: FlagUpdateSuccessToastProps) => {
|
||||
const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], mailboxId, toastId, messages, onUndo }: FlagUpdateSuccessToastProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { unmark } = useFlag(flag, { showToast: false });
|
||||
|
||||
@@ -87,6 +91,7 @@ const FlagUpdateSuccessToast = ({ flag, threadIds = [], messageIds = [], toastId
|
||||
unmark({
|
||||
threadIds: threadIds,
|
||||
messageIds: messageIds,
|
||||
mailboxId,
|
||||
onSuccess: (data) => {
|
||||
toast.dismiss(toastId);
|
||||
onUndo?.(data);
|
||||
|
||||
56
src/frontend/src/features/message/use-starred.tsx
Normal file
56
src/frontend/src/features/message/use-starred.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMailboxContext } from "../providers/mailbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useFlag from "./use-flag";
|
||||
|
||||
type MarkAsStarredOptions = {
|
||||
threadIds: string[];
|
||||
starredAt?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to mark threads as starred.
|
||||
* Starred state is scoped per mailbox via ThreadAccess.starred_at.
|
||||
*/
|
||||
const useStarred = () => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedMailbox, invalidateThreadMessages, invalidateThreadsStats } = useMailboxContext();
|
||||
|
||||
const { mark, unmark, status } = useFlag('starred', {
|
||||
toastMessages: {
|
||||
thread: (count: number) => t('{{count}} threads are now starred.', { count, defaultValue_one: 'The thread is now starred.' }),
|
||||
message: (count: number) => t('{{count}} messages are now starred.', { count, defaultValue_one: 'The message is now starred.' }),
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateThreadMessages();
|
||||
invalidateThreadsStats();
|
||||
}
|
||||
});
|
||||
|
||||
const mailboxId = selectedMailbox?.id;
|
||||
|
||||
const markAsStarred = ({ threadIds, starredAt, onSuccess }: MarkAsStarredOptions) => {
|
||||
mark({
|
||||
threadIds,
|
||||
mailboxId,
|
||||
starredAt,
|
||||
onSuccess: () => onSuccess?.(),
|
||||
});
|
||||
};
|
||||
|
||||
const markAsUnstarred = ({ threadIds, onSuccess }: MarkAsStarredOptions) => {
|
||||
unmark({
|
||||
threadIds,
|
||||
mailboxId,
|
||||
onSuccess: () => onSuccess?.(),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
markAsStarred,
|
||||
markAsUnstarred,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStarred;
|
||||
@@ -110,3 +110,18 @@
|
||||
color: var(--c--contextuals--content--semantic--info--tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.badge--yellow {
|
||||
&.badge--primary {
|
||||
background-color: var(--c--contextuals--background--palette--yellow--primary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
|
||||
}
|
||||
&.badge--secondary {
|
||||
background-color: var(--c--contextuals--background--palette--yellow--secondary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
|
||||
}
|
||||
&.badge--tertiary {
|
||||
background-color: transparent;
|
||||
color: var(--c--contextuals--background--palette--yellow--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx"
|
||||
import { HTMLAttributes, PropsWithChildren } from "react"
|
||||
|
||||
type BadgeProps = PropsWithChildren<HTMLAttributes<HTMLDivElement>> & {
|
||||
color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
|
||||
color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info' | 'yellow';
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,10 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.flex-justify-space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.p-0 {
|
||||
padding: var(--c--globals--spacings--2xs);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
@use "./../features/layouts/components/thread-view/components/calendar-invite";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-accesses-widget";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-labels-widget";
|
||||
@use "./../features/layouts/components/thread-view/components/starred-marker";
|
||||
@use "./../features/forms/components/message-form";
|
||||
@use "./../features/forms/components/search-input";
|
||||
@use "./../features/forms/components/search-filters-form";
|
||||
|
||||
Reference in New Issue
Block a user