(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:
jbpenrath
2026-03-03 22:23:45 +01:00
parent 03aac79606
commit 54fd3deb04
25 changed files with 386 additions and 80 deletions

View File

@@ -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": {

View File

@@ -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}

View File

@@ -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()

View File

@@ -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",

View File

@@ -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é",

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -18,4 +18,6 @@ export interface ThreadAccessDetail {
readonly role: ThreadAccessRoleChoices;
/** @nullable */
readonly read_at: string | null;
/** @nullable */
readonly starred_at: string | null;
}

View File

@@ -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")}

View File

@@ -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"]
}
}

View File

@@ -131,6 +131,7 @@
.thread-item__column--metadata {
align-items: center;
gap: var(--c--globals--spacings--3xs);
}
.thread-item__label-bullets {

View File

@@ -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">

View File

@@ -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')}>

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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" />,

View File

@@ -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>
)

View File

@@ -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);

View 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;

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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";