Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Bulk email from workspace #259

Merged
merged 8 commits into from
Dec 12, 2024
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
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'
},
},
},
},
)
)
Comment on lines +117 to +136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make handleSubmit non-optional and use this implementation as handleSubmit wherever this is used ? Alternatively, a simpler re-factor would be to create a new component that provides this handleSubmit implementation:

export function EmailComposer ({ did }: { did: string }) {
  return <EmailComposerBase
    did={did}
    handleSubmit={
      // tools.ozone.moderation.emitEvent + toast here
    }
    />
})

export function EmailComposerBase ({
  did,
  replacePlaceholders = true,
  handleSubmit,
}: {
  did?: string
  replacePlaceholders?: boolean
  handleSubmit?: (emailData: EmailComposerData) => Promise<void>
}) {

}

}

// 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
Loading