Skip to content

Commit

Permalink
✨ Send bulk email from workspace
Browse files Browse the repository at this point in the history
  • Loading branch information
foysalit committed Dec 11, 2024
1 parent 8e14156 commit 0494a12
Show file tree
Hide file tree
Showing 9 changed files with 271 additions and 133 deletions.
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
13 changes: 6 additions & 7 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 @@ -58,12 +62,7 @@ export const EmailComposer = ({
}: {
did?: string
replacePlaceholders?: boolean
handleSubmit?: (emailData: {
$type: typeof MOD_EVENTS.EMAIL
comment?: string
subjectLine?: string
content: string
}) => Promise<void>
handleSubmit?: (emailData: EmailComposerData) => Promise<void>
}) => {
const labelerAgent = useLabelerAgent()
const {
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
}
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
7 changes: 6 additions & 1 deletion components/mod-event/SelectorButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ export const ModEventSelectorButton = ({
return false
}
// Don't show email if user does not have permission to send email
if (key === MOD_EVENTS.EMAIL && (!canSendEmail || !isSubjectDid)) {
// 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
Expand Down
52 changes: 49 additions & 3 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 Down Expand Up @@ -79,16 +88,49 @@ 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')
}

if (eventData.event.content.includes('{{handle}}') && !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 @@ -97,17 +139,19 @@ const emitEventsInBulk = async ({
failed: [],
}

console.log(eventData, 'eventData')
const actions = Promise.allSettled(
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) {
console.error(err)
results.failed.push(sub)
}
}),
Expand Down Expand Up @@ -149,6 +193,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 @@ -166,6 +211,7 @@ export const useActionSubjects = () => {
createSubjectFromId,
subjects: chunk,
eventData,
subjectData,
})

results.succeeded.push(...succeeded)
Expand Down
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
98 changes: 66 additions & 32 deletions components/workspace/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import WorkspaceList from './List'
import { WorkspacePanelActionForm } from './PanelActionForm'
import { WorkspacePanelActions } from './PanelActions'
import { useWorkspaceListData } from './useWorkspaceListData'
import { isNonNullable } from '@/lib/util'
import { isNonNullable, isValidDid } from '@/lib/util'
import { EmailComposerData } from 'components/email/helpers'

export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
const { onClose, ...others } = props
Expand All @@ -50,13 +51,16 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
const actionSubjects = useActionSubjects()
const dropzoneRef = createRef<DropzoneRef>()

const handleRemoveSelected = () => {
const selectedItems = Array.from(
const getSelectedItems = () => {
return Array.from(
formRef.current?.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"][name="workspaceItem"]:checked',
) || [],
).map((checkbox) => checkbox.value)
removeItemsMutation.mutate(selectedItems as string[])
}

const handleRemoveSelected = () => {
removeItemsMutation.mutate(getSelectedItems())
}

const handleRemoveItem = (item: string) => {
Expand All @@ -67,6 +71,21 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
emptyWorkspaceMutation.mutate()
}

const selectFailedIems = (failedItems: string[]) => {
document
.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"][name="workspaceItem"]',
)
.forEach((checkbox) => {
if (failedItems.includes(checkbox.value)) {
checkbox.checked = true
// There's an event handler on the checkbox for mousedown event that syncs with a react state
// for last checked index. We need to trigger that event to keep the state in sync
checkbox.dispatchEvent(new Event('mousedown'))
}
})
}

const [submission, setSubmission] = useState<{
isSubmitting: boolean
error: string
Expand Down Expand Up @@ -171,6 +190,13 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
}
: undefined

const { data: workspaceList } = useWorkspaceList()
const { data: workspaceListStatuses } = useWorkspaceListData({
subjects: workspaceList || [],
// Make sure we aren't constantly refreshing the data unless the panel is open
enabled: props.open,
})

// on form submit
const onFormSubmit = async (
ev: FormEvent<HTMLFormElement> & { target: HTMLFormElement },
Expand Down Expand Up @@ -230,6 +256,7 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
const results = await actionSubjects(
{ event: coreEvent },
Array.from(formData.getAll('workspaceItem') as string[]),
workspaceListStatuses || {},
)

// After successful submission, reset the form state to clear inputs for previous submission
Expand All @@ -241,31 +268,38 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {

// If there are any item that failed to action, we want to keep them checked so users know which ones to retry
if (results.failed.length) {
document
.querySelectorAll<HTMLInputElement>(
'input[type="checkbox"][name="workspaceItem"]',
)
.forEach((checkbox) => {
if (results.failed.includes(checkbox.value)) {
checkbox.checked = true
// There's an event handler on the checkbox for mousedown event that syncs with a react state
// for last checked index. We need to trigger that event to keep the state in sync
checkbox.dispatchEvent(new Event('mousedown'))
}
})
selectFailedIems(results.failed)
}
} catch (err) {
setSubmission({ error: (err as Error).message, isSubmitting: false })
}
}

const { data: workspaceList } = useWorkspaceList()
const { data: workspaceListStatuses } = useWorkspaceListData({
subjects: workspaceList || [],
// Make sure we aren't constantly refreshing the data unless the panel is open
enabled: props.open,
})
const handleEmailSubmit = async (emailEvent: EmailComposerData) => {
setSubmission({ isSubmitting: true, error: '' })
try {
setSubmission({ isSubmitting: true, error: '' })

// No need to break if one of the requests fail, continue on with others
const results = await actionSubjects(
{ event: emailEvent },
// Emails can only be sent to DID subjects so filter out anything that's not a did
getSelectedItems().filter(isValidDid),
workspaceListStatuses || {},
)

// This state is not kept in the form and driven by state so we need to reset it manually after submission
setModEventType(MOD_EVENTS.ACKNOWLEDGE)
setSubmission({ error: '', isSubmitting: false })

// If there are any item that failed to action, we want to keep them checked so users know which ones to retry
if (results.failed.length) {
selectFailedIems(results.failed)
}
} catch (err) {
setSubmission({ error: (err as Error).message, isSubmitting: false })
}
}
return (
<FullScreenActionPanel
title={
Expand Down Expand Up @@ -329,21 +363,21 @@ export function WorkspacePanel(props: PropsOf<typeof ActionPanel>) {
onCancel={() => setShowItemCreator(false)}
/>
)}
{showActionForm && (
<WorkspacePanelActionForm
modEventType={modEventType}
setModEventType={setModEventType}
handleEmailSubmit={handleEmailSubmit}
onCancel={() => setShowActionForm((current) => !current)}
/>
)}
{/* The form component can't wrap the panel action form above because we may render the email composer */}
{/* inside the panel action form which is it's own form so we use form ids to avoid nesting forms */}
<form
ref={formRef}
id={WORKSPACE_FORM_ID}
onSubmit={onFormSubmit}
>
{showActionForm && (
<WorkspacePanelActionForm
{...{
modEventType,
setModEventType,
onCancel: () =>
setShowActionForm((current) => !current),
}}
/>
)}
{!showItemCreator && (
<div className="mb-2 flex space-x-2">
<WorkspacePanelActions
Expand Down
Loading

0 comments on commit 0494a12

Please sign in to comment.