Skip to content

Commit

Permalink
✨ Bulk email from workspace (#259)
Browse files Browse the repository at this point in the history
* ✨ Allow sending emails from quick action panel

* ✨ Allow sending emails from quick action panel

* ✨ Send bulk email from workspace

* 🚨 fix prop check for email recipient status

* ✨ Default to email event after takedown/label/reverse takedown

* ✅ Add test for bulk email sender

* 🧹 Cleanup
  • Loading branch information
foysalit authored Dec 12, 2024
1 parent 325581a commit 35fd616
Show file tree
Hide file tree
Showing 20 changed files with 842 additions and 551 deletions.
598 changes: 320 additions & 278 deletions app/actions/ModActionPanel/QuickAction.tsx

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions components/common/feeds/AuthorFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ export const useAuthorFeedQuery = ({
queryKey: ['authorFeed', { id, query, typeFilter }],
queryFn: async ({ pageParam }) => {
let isFromAppview = false
const searchPosts = query.length && repoData?.repo.handle
const searchPosts = query.length && repoData?.repo?.handle
if (searchPosts) {
const { data } = await labelerAgent.app.bsky.feed.searchPosts({
q: `from:${repoData?.repo.handle} ${query}`,
q: `from:${repoData?.repo?.handle} ${query}`,
limit: 30,
cursor: pageParam,
})
Expand Down
4 changes: 3 additions & 1 deletion components/communication-template/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Checkbox, FormLabel, Input } from '@/common/forms'
import { ActionButton } from '@/common/buttons'
import { useCommunicationTemplateEditor } from './hooks'
import { LanguageSelectorDropdown } from '@/common/LanguagePicker'
import { useColorScheme } from '@/common/useColorScheme'

const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })

Expand All @@ -27,6 +28,7 @@ export const CommunicationTemplateForm = ({
isSaving,
} = useCommunicationTemplateEditor(templateId)
const [lang, setLang] = useState<string | undefined>()
const { theme } = useColorScheme()

return (
<form onSubmit={onSubmit}>
Expand Down Expand Up @@ -66,7 +68,7 @@ export const CommunicationTemplateForm = ({
value={contentMarkdown}
onChange={(c) => setContentMarkdown(c || '')}
fullscreen={false}
data-color-mode="light"
data-color-mode={theme}
commands={[
commands.bold,
commands.divider,
Expand Down
80 changes: 53 additions & 27 deletions components/email/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { useColorScheme } from '@/common/useColorScheme'
import { MOD_EVENTS } from '@/mod-event/constants'
import { useRepoAndProfile } from '@/repositories/useRepoAndProfile'
import { useLabelerAgent } from '@/shell/ConfigurationContext'
import { compileTemplateContent, getTemplate } from './helpers'
import {
compileTemplateContent,
EmailComposerData,
getTemplate,
} from './helpers'
import { TemplateSelector } from './template-selector'
import { availableLanguageCodes } from '@/common/LanguagePicker'
import { ToolsOzoneModerationDefs } from '@atproto/api'
Expand Down Expand Up @@ -51,7 +55,15 @@ const getRecipientsLanguages = (
}
}

export const EmailComposer = ({ did }: { did: string }) => {
export const EmailComposer = ({
did,
replacePlaceholders = true,
handleSubmit,
}: {
did?: string
replacePlaceholders?: boolean
handleSubmit?: (emailData: EmailComposerData) => Promise<void>
}) => {
const labelerAgent = useLabelerAgent()
const {
isSending,
Expand Down Expand Up @@ -93,31 +105,37 @@ export const EmailComposer = ({ did }: { did: string }) => {
.processSync(content)
.toString()

await toast.promise(
labelerAgent.api.tools.ozone.moderation.emitEvent({
event: {
$type: MOD_EVENTS.EMAIL,
comment,
subjectLine: subject,
content: htmlContent,
},
subject: { $type: 'com.atproto.admin.defs#repoRef', did },
createdBy: labelerAgent.assertDid,
}),
{
pending: 'Sending email...',
success: {
render() {
return 'Email sent to user'
const event = {
$type: MOD_EVENTS.EMAIL,
comment,
subjectLine: subject,
content: htmlContent,
}
if (handleSubmit) {
await handleSubmit(event)
} else {
await toast.promise(
labelerAgent.tools.ozone.moderation.emitEvent({
event,
createdBy: labelerAgent.assertDid,
subject: { $type: 'com.atproto.admin.defs#repoRef', did },
}),
{
pending: 'Sending email...',
success: {
render() {
return 'Email sent to user'
},
},
},
error: {
render() {
return 'Error sending email'
error: {
render() {
return 'Error sending email'
},
},
},
},
)
)
}

// Reset the form if email is sent successfully
e.target.reset()
reset()
Expand All @@ -137,9 +155,17 @@ export const EmailComposer = ({ did }: { did: string }) => {
return
}
const subject = template.subject || ''
const content = compileTemplateContent(template.contentMarkdown, {
handle: repo?.handle,
})
// When email is sent to one recipient at a time, we know how to replace the placeholders
// based on the individual recipient's data on hand. However, when sending it to bulk recipients
// we only know those details at send time so replacing them in the editor doesn't really work
const content = compileTemplateContent(
template.contentMarkdown,
replacePlaceholders
? {
handle: repo?.handle,
}
: {},
)
setContent(content)
if (subjectField.current) subjectField.current.value = subject
if (commentField.current)
Expand Down
8 changes: 8 additions & 0 deletions components/email/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MOD_EVENTS } from '@/mod-event/constants'
import { ToolsOzoneCommunicationDefs } from '@atproto/api'

export const getTemplate = (
Expand All @@ -21,3 +22,10 @@ export const compileTemplateContent = (

return content
}

export type EmailComposerData = {
$type: typeof MOD_EVENTS.EMAIL
comment?: string
subjectLine?: string
content: string
}
4 changes: 3 additions & 1 deletion components/email/useEmailRecipientStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { resolveDidDocData } from '@/lib/identity'
import { useQuery } from '@tanstack/react-query'

export const useEmailRecipientStatus = (
did: string,
did?: string,
): { isLoading: boolean; error: any; cantReceive: boolean } => {
const { data, isLoading, error } = useQuery({
queryKey: ['email-capability-check', did],
queryFn: async () => {
// If no did is provided, we can't check if the recipient can receive emails
if (!did) return true
const response = await resolveDidDocData(did)
if (!response?.services) {
return false
Expand Down
10 changes: 10 additions & 0 deletions components/mod-event/DetailsPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ const ModEventDetails = ({ modEventType }: { modEventType: string }) => {
)
}

if (modEventType === MOD_EVENTS.EMAIL) {
return (
<p>
This event sends email to users. Sending the email depends on PDS
implementation and your labeler configuration. Not all labelers can send
emails to all users on the network.
</p>
)
}

return (
<p>
Sorry, this event is not well defined and probably will not have any
Expand Down
14 changes: 14 additions & 0 deletions components/mod-event/SelectorButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const actions = [
text: 'Resolve Appeal',
key: MOD_EVENTS.RESOLVE_APPEAL,
},
{
text: 'Send Email',
key: MOD_EVENTS.EMAIL,
},
{
text: 'Divert',
key: MOD_EVENTS.DIVERT,
Expand Down Expand Up @@ -80,6 +84,7 @@ export const ModEventSelectorButton = ({
const canDivertBlob = usePermission('canDivertBlob')
const canTakedown = usePermission('canTakedown')
const canManageChat = usePermission('canManageChat')
const canSendEmail = usePermission('canSendEmail')

const availableActions = useMemo(() => {
return actions.filter(({ key }) => {
Expand All @@ -95,6 +100,15 @@ export const ModEventSelectorButton = ({
if (key === MOD_EVENTS.APPEAL && subjectStatus?.appealed) {
return false
}
// Don't show email if user does not have permission to send email
// or if the subject is not a DID but override that if it is set to be force displayed
if (
key === MOD_EVENTS.EMAIL &&
(!canSendEmail ||
(!isSubjectDid && !forceDisplayActions.includes(MOD_EVENTS.EMAIL)))
) {
return false
}
// Don't show takedown action if subject is already takendown
if (
(key === MOD_EVENTS.TAKEDOWN || key === MOD_EVENTS.DIVERT) &&
Expand Down
68 changes: 63 additions & 5 deletions components/mod-event/helpers/emitEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Link from 'next/link'
import { toast } from 'react-toastify'
import { Agent, ToolsOzoneModerationEmitEvent } from '@atproto/api'
import {
Agent,
ToolsOzoneModerationDefs,
ToolsOzoneModerationEmitEvent,
} from '@atproto/api'
import { useQueryClient } from '@tanstack/react-query'

import { buildItemsSummary, groupSubjects } from '@/workspace/utils'
Expand All @@ -11,6 +15,11 @@ import { useLabelerAgent } from '@/shell/ConfigurationContext'
import { useCallback } from 'react'
import { useCreateSubjectFromId } from '@/reports/helpers/subject'
import { chunkArray } from '@/lib/util'
import {
WorkspaceListData,
WorkspaceListItemData,
} from '@/workspace/useWorkspaceListData'
import { compileTemplateContent } from 'components/email/helpers'

export function useEmitEvent() {
const labelerAgent = useLabelerAgent()
Expand All @@ -34,7 +43,7 @@ export function useEmitEvent() {
const eventType = data?.event.$type as string
const actionTypeString = eventType && eventTexts[eventType]

const title = `${isRecord ? 'Record' : 'Repo'} was ${
const title = `${isRecord ? 'Record' : 'Account'} was ${
actionTypeString ?? 'actioned'
}`

Expand Down Expand Up @@ -79,16 +88,54 @@ type BulkActionResults = {
failed: string[]
}

const eventForSubject = (
eventData: Pick<ToolsOzoneModerationEmitEvent.InputSchema, 'event'>,
subjectData: WorkspaceListItemData,
): Pick<ToolsOzoneModerationEmitEvent.InputSchema, 'event'> => {
// only need to adjust event data for each subject for email events
// for the rest, same event data is used for all subjects
if (!ToolsOzoneModerationDefs.isModEventEmail(eventData.event)) {
return eventData
}

if (!eventData.event.content) {
throw new Error('Email content is required for email events')
}

const hasPlaceholder = eventData.event.content.includes('{{handle}}')
if (!hasPlaceholder) {
return eventData
}

if (!subjectData) {
throw new Error(
'Email content has template placeholder but no handle account data found',
)
}

return {
...eventData,
event: {
...eventData.event,
content: compileTemplateContent(eventData.event.content, {
handle: subjectData.handle,
}),
},
}
}

const emitEventsInBulk = async ({
labelerAgent,
createSubjectFromId,
subjects,
eventData,
subjectData,
}: {
labelerAgent: Agent
createSubjectFromId: ReturnType<typeof useCreateSubjectFromId>
subjects: string[]
eventData: Pick<ToolsOzoneModerationEmitEvent.InputSchema, 'event'>
subjectData: WorkspaceListData
}) => {
const toastId = 'workspace-bulk-action'
try {
Expand All @@ -101,10 +148,10 @@ const emitEventsInBulk = async ({
subjects.map(async (sub) => {
try {
const { subject } = await createSubjectFromId(sub)
await labelerAgent.api.tools.ozone.moderation.emitEvent({
await labelerAgent.tools.ozone.moderation.emitEvent({
...eventForSubject(eventData, subjectData[sub]),
subject,
createdBy: labelerAgent.assertDid,
...eventData,
})
results.succeeded.push(sub)
} catch (err) {
Expand Down Expand Up @@ -149,6 +196,7 @@ export const useActionSubjects = () => {
async (
eventData: Pick<ToolsOzoneModerationEmitEvent.InputSchema, 'event'>,
subjects: string[],
subjectData: WorkspaceListData,
) => {
if (!subjects.length) {
toast.error(`No subject to action`)
Expand All @@ -160,12 +208,19 @@ export const useActionSubjects = () => {
failed: [],
}

for (const chunk of chunkArray(subjects, 50)) {
// Emails have a lower limit per second so we want to make sure we are well below that
const chunkSize = ToolsOzoneModerationDefs.isModEventEmail(
eventData.event,
)
? 25
: 50
for (const chunk of chunkArray(subjects, chunkSize)) {
const { succeeded, failed } = await emitEventsInBulk({
labelerAgent,
createSubjectFromId,
subjects: chunk,
eventData,
subjectData,
})

results.succeeded.push(...succeeded)
Expand All @@ -192,4 +247,7 @@ const eventTexts = {
[MOD_EVENTS.LABEL]: 'labeled',
[MOD_EVENTS.MUTE]: 'muted',
[MOD_EVENTS.UNMUTE]: 'unmuted',
[MOD_EVENTS.APPEAL]: 'appealed',
[MOD_EVENTS.RESOLVE_APPEAL]: 'appealed',
[MOD_EVENTS.EMAIL]: 'emailed',
}
2 changes: 1 addition & 1 deletion components/reports/SubjectOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const CollectionLink = ({

return (
<>
<Link href={`/repositories/${repoUrl}`} target="_blank">
<Link href={`/repositories/${repoUrl}`} target="_blank" prefetch={false}>
<ArrowTopRightOnSquareIcon className="inline-block h-4 w-4 mr-1" />
</Link>
<Link
Expand Down
7 changes: 6 additions & 1 deletion components/repositories/useRepoAndProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import { getDidFromHandle } from '@/lib/identity'
import { useLabelerAgent } from '@/shell/ConfigurationContext'
import { useQuery } from '@tanstack/react-query'

export const useRepoAndProfile = ({ id }: { id: string }) => {
export const useRepoAndProfile = ({ id }: { id?: string }) => {
const labelerAgent = useLabelerAgent()
return useQuery({
queryKey: ['accountView', { id }],
enabled: !!id,
queryFn: async () => {
if (!id) {
return { repo: undefined, profile: undefined }
}

const getRepo = async () => {
let did
if (id.startsWith('did:')) {
Expand Down
Loading

0 comments on commit 35fd616

Please sign in to comment.