Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions frontend/src/apps/mail/components/MailListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
:selection-mode
:unread="!mail.seen"
:hide-sender
:avatar-label="getSenderInitial(mail)"
:avatar-label="avatarLabel"
:avatar-image="mail.user_image"
:datetime="mail.received_at"
:subject-italic="!mail.subject"
Expand All @@ -16,6 +16,14 @@
<template #sender><span v-html="highlight(header)" /></template>

<template #badges>
<!-- How many messages the thread holds — only worth saying once it holds more than one. -->
<span
v-if="messageCount > 1"
class="text-ink-gray-5 shrink-0 text-xs"
:aria-label="__('{0} messages', [messageCount])"
>
{{ messageCount }}
</span>
<!-- All Inboxes: which account received this mail. -->
<div v-if="accountLabel" class="text-ink-gray-4 flex shrink-0 items-center gap-1 text-xs">
<span aria-hidden="true">·</span>
Expand Down Expand Up @@ -148,6 +156,7 @@ import { Badge, Popover, Tooltip } from 'frappe-ui'

import { getAttachmentUrl } from '@/apps/mail/resources'
import { downloadUrlAsFile, getFileIcon, getFormattedRecipients, getSenderInitial } from '@/apps/mail/utils'
import { formatThreadParticipants, primaryParticipant } from '@/apps/mail/utils/participants'
import { userStore } from '@/apps/mail/stores/user'
import AttachmentCapsule from '@/apps/mail/components/AttachmentCapsule.vue'
import AttachmentViewer from '@/apps/mail/components/AttachmentViewer.vue'
Expand Down Expand Up @@ -204,21 +213,46 @@ const to = computed(() => ({
query: route.query,
}))

const mailboxes = computed(() => mail.mailboxes.map((m) => m.mailbox_id))

const mailboxesToShow = computed(() => mail.mailboxes.filter((m) => m.mailbox_id !== mailbox))

const attachments = computed(
() => mail.attachments.filter((m) => m.filename && m.disposition === 'attachment') || [],
)

// Sent and Drafts are about who the mail is going to, so those rows name the recipients. Everywhere
// else the row names the thread's participants.
//
// In a mailbox it is the VIEW that decides this, not whether the thread happens to hold a sent
// message: a thread you have answered carries a sent copy wherever it sits, so testing the row's
// mailboxes named recipients all over Inbox and Archive. Worse, those mailboxes come from the
// thread's message in the current mailbox while `recipients` comes from its latest message anywhere
// — on an archived thread whose newest mail is an incoming reply, the row took "outgoing" from its
// own sent copy and then named that reply's recipient: you.
//
// Search results are the exception: they are single messages with no thread behind them and no
// participants to name, so there the message's own mailboxes still answer it — otherwise a mail you
// sent, found in search, would go by your own name rather than by who you sent it to.
const isOutgoing = computed(() => {
if (mailbox === 'search')
return mail.mailboxes.some(
(m) => m.mailbox_id === mailboxIds.sent || m.mailbox_id === mailboxIds.drafts,
)
return mailbox === mailboxIds.sent || mailbox === mailboxIds.drafts
})

const header = computed(() => {
const isOutgoing =
mailboxes.value.includes(mailboxIds.sent) || mailboxes.value.includes(mailboxIds.drafts)
if (isOutgoing.value) return getFormattedRecipients(mail.recipients) || __('To:')
return formatThreadParticipants(mail.participants ?? []) || mail.from_name || mail.from_email
})

// Gmail-style count of how many messages the conversation holds, shown once there's more than one.
const messageCount = computed(() => mail.messages?.length ?? 0)

return isOutgoing
? getFormattedRecipients(mail.recipients) || __('To:')
: mail.from_name || mail.from_email
// The letter behind the avatar, for a contact with no picture: whoever the picture itself would be of.
const avatarLabel = computed(() => {
const primary = primaryParticipant(mail.participants ?? [])
if (!primary) return getSenderInitial(mail)
return getSenderInitial({ from_name: primary.name, from_email: primary.email })
})

// In search results, highlight the matched query term. Escape the text first (so any markup in the
Expand Down
34 changes: 30 additions & 4 deletions frontend/src/apps/mail/components/StackListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<MailRow
:is-selected
:unread="!!unreadCount"
:avatar-label="getSenderInitial(latest)"
:avatar-label="avatarLabel"
:avatar-image="latest.user_image"
:datetime="latest.received_at"
:subject-italic="!latest.subject"
Expand All @@ -12,10 +12,19 @@
@click="emit('toggle')"
@set-selected="(selected: boolean) => emit('setSelected', selected)"
>
<template #sender>{{ latest.from_name || latest.from_email }}</template>
<template #sender>{{ sender }}</template>

<!-- The glyph is what separates a pile of threads from the plain number a single thread wears to
say how many messages it holds — at a glance the two counts were the same small grey digit. -->
<template #badges>
<Badge size="sm" :label="String(threads.length)" />
<Badge size="sm" :aria-label="__('{0} conversations', [threads.length])">
<span class="flex items-center gap-1">
<!-- Badge's own prefix slot boxes an icon at 10px, where the glyph's strokes close up.
The default slot leaves room for the 12px the design system's icons are drawn at. -->
<Layers2 class="h-3 w-3 stroke-[1.5]" />
{{ threads.length }}
</span>
</Badge>
</template>

<template #subject>{{ latest.subject || __('[No subject]') }}</template>
Expand All @@ -40,10 +49,11 @@

<script setup lang="ts">
import { computed } from 'vue'
import { ChevronDown, ChevronRight } from 'lucide-vue-next'
import { ChevronDown, ChevronRight, Layers2 } from 'lucide-vue-next'
import { Badge } from 'frappe-ui'

import { getSenderInitial } from '@/apps/mail/utils'
import { formatThreadParticipants, primaryParticipant } from '@/apps/mail/utils/participants'
import MailRow from '@/apps/mail/components/MailRow.vue'
import MailRowActions from '@/apps/mail/components/MailRowActions.vue'

Expand Down Expand Up @@ -72,5 +82,21 @@ const emit = defineEmits<{
// The list is newest-first, so the run's first member is its latest — the row this stack stands in for.
const latest = computed(() => threads[0])

// Only threads with a lone sender stack (see stackKeyOf), and every member shares that sender, so the
// stack is named exactly the way each of its members is — collapsed or expanded, the name holds.
const sender = computed(
() =>
formatThreadParticipants(latest.value.participants ?? []) ||
latest.value.from_name ||
latest.value.from_email,
)

// The letter behind the avatar, for a contact with no picture: whoever the picture itself would be of.
const avatarLabel = computed(() => {
const primary = primaryParticipant(latest.value.participants ?? [])
if (!primary) return getSenderInitial(latest.value)
return getSenderInitial({ from_name: primary.name, from_email: primary.email })
})

const unreadCount = computed(() => threads.filter((t) => !t.seen).length)
</script>
10 changes: 10 additions & 0 deletions frontend/src/apps/mail/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ export interface Recipient {
display_name: string | null
}

// Everyone who has written in a thread, in the order they first wrote (see serialize_participants).
// `is_self` is resolved server-side against the addresses of the account the thread belongs to —
// not always the active one, since All Inboxes merges rows from every account the user owns.
export interface ThreadParticipant {
name: string
email: string
is_self: boolean
}

export interface Mailbox {
mailbox: string
mailbox_id: string
Expand Down Expand Up @@ -169,6 +178,7 @@ export interface Thread {
thread_id: string
from_name: string
from_email: string
participants?: ThreadParticipant[]
subject: string | null
preview: string | null
has_attachment: 0 | 1
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/apps/mail/utils/participants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { beforeAll, describe, expect, it } from 'vitest'

import { formatThreadParticipants } from './participants'

import type { ThreadParticipant } from '@/apps/mail/types'

// `__` is installed on window by the translation plugin at app boot; the util calls it at format time,
// so standing it up before the first test is enough.
beforeAll(() => {
window.__ = (message: string) => message
})

const participant = (name: string, email: string, is_self = false): ThreadParticipant => ({
name,
email,
is_self,
})

describe('formatThreadParticipants', () => {
it('names a lone sender in full', () => {
expect(formatThreadParticipants([participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io')])).toBe(
'Sarfaraz Shaikh',
)
})

it('keeps the original sender ahead of the user who replied', () => {
const thread = [
participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
]
expect(formatThreadParticipants(thread)).toBe('Sarfaraz, me')
})

it('capitalizes "me" only where it heads the row', () => {
const thread = [
participant('Vibhav Katre', 'vibhav@frappe.io', true),
participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
]
expect(formatThreadParticipants(thread)).toBe('Me, Sarfaraz')
expect(formatThreadParticipants([participant('Vibhav Katre', 'vibhav@frappe.io', true)])).toBe(
'Me',
)
})

it('says "me" once however many of the user\'s addresses wrote', () => {
const thread = [
participant('Sarfaraz Shaikh', 'sarfaraz@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
participant('Vibhav', 'vibhav@example.com', true),
]
expect(formatThreadParticipants(thread)).toBe('Sarfaraz, me')
})

it('lists every name up to the limit', () => {
const thread = [
participant('Brittany Court', 'brittany@frappe.io'),
participant('Milind Jain', 'milind@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
]
expect(formatThreadParticipants(thread)).toBe('Brittany, Milind, me')
})

it('elides as soon as the thread runs one name past the limit', () => {
const thread = [
participant('Brittany Court', 'brittany@frappe.io'),
participant('Milind Jain', 'milind@frappe.io'),
participant('Neha Sankhe', 'neha@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
]
expect(formatThreadParticipants(thread)).toBe('Brittany … Neha, me')
})

it('elides the middle of a long thread, keeping its ends', () => {
const thread = [
participant('Brittany Court', 'brittany@frappe.io'),
participant('Milind Jain', 'milind@frappe.io'),
participant('Neha Sankhe', 'neha@frappe.io'),
participant('Courtney Diaz', 'courtney@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
]
expect(formatThreadParticipants(thread)).toBe('Brittany … Courtney, me')
})

it('falls back to the address of a sender who goes by no name', () => {
expect(formatThreadParticipants([participant('', 'noreply@frappe.io')])).toBe(
'noreply@frappe.io',
)
expect(
formatThreadParticipants([
participant(' ', 'noreply@frappe.io'),
participant('Vibhav Katre', 'vibhav@frappe.io', true),
]),
).toBe('noreply@frappe.io, me')
})

it('has nothing to say about a thread with no senders', () => {
expect(formatThreadParticipants([])).toBe('')
})
})
57 changes: 57 additions & 0 deletions frontend/src/apps/mail/utils/participants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ThreadParticipant } from '@/apps/mail/types'

// Past this many names the middle of the list is dropped for an ellipsis, keeping the
// thread's first sender and its two most recent — the ends identify a conversation.
const MAX_PARTICIPANTS_SHOWN = 3

const capitalizeFirst = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)

/**
* The participant a row's avatar stands for: the first person in the thread who isn't the user,
* falling back to the user themselves for a thread only they have written in.
*
* The avatar IMAGE is chosen server-side by the same rule (add_user_images_to_emails skips the user's
* own addresses), so deriving the fallback letter from anywhere else — the latest sender, say, which is
* the user on any thread they have answered — puts one person's initial on another's photo.
*/
export const primaryParticipant = (participants: ThreadParticipant[]): ThreadParticipant | undefined =>
participants.find((p) => !p.is_self) ?? participants[0]

/**
* The name(s) a thread row goes by: everyone who has written in it, in the order they
* first wrote, with the user's own addresses collapsed to "me" — "Alice, me",
* "Alice … Carol, me", "Me, Alice".
*
* A thread is named after its participants rather than after its latest sender because
* replying to one made the row read as though the user had sent it: an incoming mail
* answered once was filed under the answerer's own name, with no trace of who had
* written in. Everyone keeps their place in the conversation, so an inbox thread still
* leads with whoever started it.
*
* Several names are shortened to first names to fit the line; a lone participant keeps
* their full one.
*/
export const formatThreadParticipants = (participants: ThreadParticipant[]) => {
// The user may have written from more than one of their addresses; they're still one name.
const firstSelf = participants.findIndex((p) => p.is_self)
const distinct = participants.filter((p, i) => !p.is_self || i === firstSelf)
if (!distinct.length) return ''

const nameOf = ({ name, email, is_self }: ThreadParticipant, firstNameOnly: boolean) => {
if (is_self) return __('me')
const fullName = name.trim()
if (!fullName) return email
return firstNameOnly ? fullName.split(/\s+/)[0] : fullName
}

// "me" is a word standing in for a name, so it reads lowercase inside the line ("Figma, me")
// and is capitalized only where it heads the row. Real names arrive capitalized already, and an
// address standing in for a missing name ("noreply@frappe.io") must be left exactly as it is.
const names = distinct.map((participant, i) => {
const name = nameOf(participant, distinct.length > 1)
return i === 0 && participant.is_self ? capitalizeFirst(name) : name
})

if (names.length <= MAX_PARTICIPANTS_SHOWN) return names.join(', ')
return `${names[0]} … ${names.slice(-2).join(', ')}`
}
Loading
Loading