feat: delayed comments

This commit is contained in:
Nevo David 2026-01-07 12:29:22 +07:00
parent 4836b2e7c1
commit 2544e870aa
12 changed files with 529 additions and 25 deletions

View File

@ -148,7 +148,7 @@ export const AddEditModalInnerInner: FC<AddEditModalProps> = (props) => {
0,
existingData.integration,
existingData.posts.map((post) => ({
delay: 0,
delay: post.delay,
content:
post.content.indexOf('<p>') > -1
? post.content

View File

@ -1,17 +1,42 @@
'use client';
import React, { FC, useCallback } from 'react';
import { DelayIcon } from '@gitroom/frontend/components/ui/icons';
import React, { FC, useCallback, useEffect, useState } from 'react';
import { DelayIcon, DropdownArrowIcon } from '@gitroom/frontend/components/ui/icons';
import clsx from 'clsx';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
import { useShallow } from 'zustand/react/shallow';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { useClickOutside } from '@mantine/hooks';
const delayOptions = [
{ value: 1, label: '1m' },
{ value: 2, label: '2m' },
{ value: 5, label: '5m' },
{ value: 10, label: '10m' },
{ value: 15, label: '15m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' },
];
export const DelayComponent: FC<{
currentIndex: number;
currentDelay: number;
}> = ({ currentIndex, currentDelay }) => {
const t = useT();
const [isOpen, setIsOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
const isCustomDelay = currentDelay > 0 && !delayOptions.some((opt) => opt.value === currentDelay);
useEffect(() => {
if (isOpen && isCustomDelay) {
setCustomValue(String(currentDelay));
} else if (isOpen && !isCustomDelay) {
setCustomValue('');
}
}, [isOpen, isCustomDelay, currentDelay]);
const { current, setInternalDelay, setGlobalDelay } = useLaunchStore(
useShallow((state) => ({
current: state.current,
@ -20,6 +45,13 @@ export const DelayComponent: FC<{
}))
);
const ref = useClickOutside(() => {
if (!isOpen) {
return;
}
setIsOpen(false);
});
const setDelay = useCallback(
(index: number) => (minutes: number) => {
if (current !== 'global') {
@ -31,20 +63,92 @@ export const DelayComponent: FC<{
[currentIndex, current]
);
const handleSelectDelay = useCallback(
(minutes: number) => {
setDelay(currentIndex)(minutes);
setIsOpen(false);
},
[currentIndex, setDelay]
);
const getCurrentDelayLabel = () => {
if (!currentDelay) return null;
const option = delayOptions.find((opt) => opt.value === currentDelay);
return option?.label || `${currentDelay} min`;
};
return (
<DelayIcon
// move it into the modal
onClick={() => setDelay(currentIndex)(100)}
data-tooltip-id="tooltip"
data-tooltip-content={
!currentDelay
? t('delay_comment', 'Delay comment')
: `Comment delayed by ${currentDelay} minutes`
}
className={clsx(
'cursor-pointer',
currentDelay > 0 && 'bg-[#D82D7E] text-white rounded-full'
<div ref={ref} className="relative">
<div
onClick={() => setIsOpen(!isOpen)}
data-tooltip-id="tooltip"
data-tooltip-content={
!currentDelay
? t('delay_comment', 'Delay comment')
: `${t('delay_comment_by', 'Comment delayed by')} ${getCurrentDelayLabel()}`
}
className={clsx(
'cursor-pointer flex items-center gap-[4px]',
currentDelay > 0 && 'bg-[#D82D7E] text-white rounded-full'
)}
>
<DelayIcon />
</div>
{isOpen && (
<div className="z-[300] absolute end-0 top-[100%] w-[200px] bg-newBgColorInner p-[8px] menu-shadow translate-y-[10px] flex flex-col rounded-[8px]">
<div className="grid grid-cols-4 gap-[4px]">
{delayOptions.map((option) => (
<div
onClick={() => handleSelectDelay(option.value)}
key={option.value}
className={clsx(
'h-[32px] flex items-center justify-center rounded-[4px] cursor-pointer hover:bg-newBgColor text-[13px]',
currentDelay === option.value && 'bg-[#612BD3] text-white hover:bg-[#612BD3]'
)}
>
{option.label}
</div>
))}
</div>
<div className="border-t border-newTextColor/10 mt-[8px] pt-[8px]">
<div className="flex gap-[4px]">
<input
type="number"
min="1"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Custom min"
className={clsx(
'flex-1 w-full h-[32px] px-[8px] rounded-[4px] bg-newBgColor border text-[13px] outline-none focus:border-[#612BD3]',
isCustomDelay ? 'border-[#612BD3]' : 'border-newTextColor/10'
)}
/>
<button
onClick={(e) => {
e.stopPropagation();
const value = parseInt(customValue, 10);
if (value > 0) {
handleSelectDelay(value);
setCustomValue('');
}
}}
className="h-[32px] px-[10px] rounded-[4px] bg-[#612BD3] text-white text-[12px] font-[600] hover:bg-[#612BD3]/80"
>
Set
</button>
</div>
</div>
{currentDelay > 0 && (
<button
onClick={() => handleSelectDelay(0)}
className="mt-[8px] h-[32px] w-full rounded-[4px] text-[13px] text-red-400 hover:bg-red-400/10"
>
Remove delay
</button>
)}
</div>
)}
/>
</div>
);
};

View File

@ -315,6 +315,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
value: post.values.map((value: any) => ({
...(value.id ? { id: value.id } : {}),
content: value.content,
delay: value.delay || 0,
image:
(value?.media || []).map(
({ id, path, alt, thumbnail, thumbnailTimestamp }: any) => ({

View File

@ -22,7 +22,6 @@ import {
organizationId,
postId as postIdSearchParam,
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
import { postWorkflow } from '@gitroom/orchestrator/workflows';
@Injectable()
@Activity()
@ -43,7 +42,7 @@ export class PostActivity {
for (const post of list) {
await this._temporalService.client
.getRawClient()
.workflow.signalWithStart('postWorkflow', {
.workflow.signalWithStart('postWorkflowV101', {
workflowId: `post_${post.id}`,
taskQueue: 'main',
signal: 'poke',

View File

@ -1,4 +1,5 @@
export * from './post.workflow';
export * from './post-workflows/post.workflow';
export * from './post-workflows/post.workflow.v1.0.1';
export * from './autopost.workflow';
export * from './digest.email.workflow';
export * from './missing.post.workflow';

View File

@ -0,0 +1,375 @@
import { PostActivity } from '@gitroom/orchestrator/activities/post.activity';
import {
ActivityFailure,
ApplicationFailure,
startChild,
proxyActivities,
sleep,
defineSignal,
setHandler,
} from '@temporalio/workflow';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { capitalize, sortBy } from 'lodash';
import { PostResponse } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { TypedSearchAttributes } from '@temporalio/common';
import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const proxyTaskQueue = (taskQueue: string) => {
return proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
taskQueue,
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
};
const {
getPostsList,
inAppNotification,
changeState,
updatePost,
sendWebhooks,
isCommentable,
} = proxyActivities<PostActivity>({
startToCloseTimeout: '10 minute',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
const poke = defineSignal('poke');
export async function postWorkflowV101({
taskQueue,
postId,
organizationId,
postNow = false,
}: {
taskQueue: string;
postId: string;
organizationId: string;
postNow?: boolean;
}) {
// Dynamic task queue, for concurrency
const {
postSocial,
postComment,
refreshToken,
internalPlugs,
globalPlugs,
processInternalPlug,
processPlug,
} = proxyTaskQueue(taskQueue);
let poked = false;
setHandler(poke, () => {
poked = true;
});
const startTime = new Date();
// get all the posts and comments to post
const postsList = await getPostsList(organizationId, postId);
const [post] = postsList;
// in case doesn't exists for some reason, fail it
if (!post || (!postNow && post.state !== 'QUEUE')) {
return;
}
// if it's a repeatable post, we should ignore this.
if (!postNow) {
await sleep(
dayjs(post.publishDate).isBefore(dayjs())
? 0
: dayjs(post.publishDate).diff(dayjs(), 'millisecond')
);
}
// if refresh is needed from last time, let's inform the user
if (post.integration?.refreshNeeded) {
await inAppNotification(
post.organizationId,
`We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`,
`We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`,
true,
false,
'info'
);
return;
}
// if it's disabled, inform the user
if (post.integration?.disabled) {
await inAppNotification(
post.organizationId,
`We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`,
`We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`,
true,
false,
'info'
);
return;
}
// Do we need to post comment for this social?
const toComment =
postsList.length === 1 ? false : await isCommentable(post.integration);
// list of all the saved results
const postsResults: PostResponse[] = [];
// iterate over the posts
for (let i = 0; i < postsList.length; i++) {
// this is a small trick to repeat an action in case of token refresh
while (true) {
try {
// first post the main post
if (i === 0) {
postsResults.push(
...(await postSocial(post.integration as Integration, [
postsList[i],
]))
);
// then post the comments if any
} else {
if (!toComment) {
break;
}
if (postsList[i].delay) {
await sleep(60000 * postsList[i].delay);
}
postsResults.push(
...(await postComment(
postsResults[0].postId,
postsResults.length === 1
? undefined
: postsResults[i - 1].postId,
post.integration,
[postsList[i]]
))
);
}
// mark post as successful
await updatePost(
postsList[i].id,
postsResults[i].postId,
postsResults[i].releaseURL
);
if (i === 0) {
// send notification on a sucessful post
await inAppNotification(
post.integration.organizationId,
`Your post has been published on ${capitalize(
post.integration.providerIdentifier
)}`,
`Your post has been published on ${capitalize(
post.integration.providerIdentifier
)} at ${postsResults[0].releaseURL}`,
true,
true
);
}
// break the current while to move to the next post
break;
} catch (err) {
// if token refresh is needed, do it and repeat
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshToken(post.integration);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
// for other errors, change state and inform the user if needed
await changeState(postsList[0].id, 'ERROR', err, postsList);
// specific case for bad body errors
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'bad_body'
) {
await inAppNotification(
post.organizationId,
`Error posting${i === 0 ? ' ' : ' comments '}on ${post.integration?.providerIdentifier} for ${post?.integration?.name}`,
`An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${
post.integration?.providerIdentifier
}${err?.cause?.message ? `: ${err?.cause?.message}` : ``}`,
true,
false,
'fail'
);
return false;
}
return false;
}
}
}
// send webhooks for the post
await sendWebhooks(
postsResults[0].postId,
post.organizationId,
post.integration.id
);
// load internal plugs like repost by other users
const internalPlugsList = await internalPlugs(
post.integration,
JSON.parse(post.settings)
);
// load global plugs, like repost a post if it gets to a certain number of likes
const globalPlugsList = (await globalPlugs(post.integration)).reduce(
(all, current) => {
for (let i = 1; i <= current.totalRuns; i++) {
all.push({
...current,
delay: current.delay * i,
});
}
return all;
},
[]
);
// Check if the post is repeatable
const repeatPost = !post.intervalInDays
? []
: [
{
type: 'repeat-post',
delay:
post.intervalInDays * 24 * 60 * 60 * 1000 -
(new Date().getTime() - startTime.getTime()),
},
];
// Sort all the actions by delay, so we can process them in order
const list = sortBy(
[...internalPlugsList, ...globalPlugsList, ...repeatPost],
'delay'
);
// process all the plugs in order, we are using while because in some cases we need to remove items from the list
while (list.length > 0) {
// get the next to process
const todo = list.shift();
// wait for the delay
await sleep(todo.delay);
// process internal plug
if (todo.type === 'internal-plug') {
while (true) {
try {
await processInternalPlug({ ...todo, post: postsResults[0].postId });
} catch (err) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshToken(post.integration);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
}
break;
}
}
// process global plug
if (todo.type === 'global') {
while (true) {
try {
const process = await processPlug({
...todo,
postId: postsResults[0].postId,
});
if (process) {
const toDelete = list
.reduce((all, current, index) => {
if (current.plugId === todo.plugId) {
all.push(index);
}
return all;
}, [])
.reverse();
for (const index of toDelete) {
list.splice(index, 1);
}
}
} catch (err) {
if (
err instanceof ActivityFailure &&
err.cause instanceof ApplicationFailure &&
err.cause.type === 'refresh_token'
) {
const refresh = await refreshToken(post.integration);
if (!refresh || !refresh.accessToken) {
await changeState(postsList[0].id, 'ERROR', err, postsList);
return false;
}
post.integration.token = refresh.accessToken;
continue;
}
}
break;
}
}
// process repeat post in a new workflow, this is important so the other plugs can keep running
if (todo.type === 'repeat-post') {
await startChild(postWorkflowV101, {
parentClosePolicy: 'ABANDON',
args: [
{
taskQueue,
postId,
organizationId,
postNow: true,
},
],
workflowId: `post_${post.id}_${makeId(10)}`,
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: postId,
},
]),
});
}
}
}

View File

@ -18,7 +18,6 @@ import { TemporalService } from 'nestjs-temporal-core';
import { TypedSearchAttributes } from '@temporalio/common';
import {
organizationId,
postId as postIdSearchParam,
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const parser = new Parser();
@ -106,7 +105,7 @@ export class AutopostService {
try {
return this._temporalService.client
.getRawClient()
?.workflow.start('postWorkflow', {
?.workflow.start('postWorkflowV101', {
workflowId: `autopost-${id}`,
taskQueue: 'main',
args: [{ id, immediately: true }],
@ -286,6 +285,7 @@ export class AutopostService {
value: [
{
id: makeId(10),
delay: 0,
content:
state.description.replace(/\n/g, '\n\n') +
'\n\n' +

View File

@ -383,6 +383,7 @@ export class PostsRepository {
}
: {}),
content: value.content,
delay: value.delay || 0,
group: uuid,
intervalInDays: inter ? +inter : null,
approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO,

View File

@ -465,7 +465,7 @@ export class PostsService {
await this._temporalService.client
.getRawClient()
?.workflow.start('postWorkflow', {
?.workflow.start('postWorkflowV101', {
workflowId: `post_${posts[0].id}`,
taskQueue: 'main',
args: [
@ -533,7 +533,7 @@ export class PostsService {
await this._temporalService.client
.getRawClient()
?.workflow.start('postWorkflow', {
?.workflow.start('postWorkflowV101', {
workflowId: `post_${getPostById.id}`,
taskQueue: 'main',
args: [
@ -622,10 +622,12 @@ export class PostsService {
...toPost.list.map((l) => ({
id: '',
content: l.post,
delay: 0,
image: [],
})),
{
id: '',
delay: 0,
content: `Check out the full story here:\n${
body.postId || body.url
}`,

View File

@ -385,6 +385,7 @@ model Post {
organizationId String
integrationId String
content String
delay Int @default(0)
group String
title String?
description String?

View File

@ -1,9 +1,25 @@
import {
ArrayMinSize, IsArray, IsBoolean, IsDateString, IsDefined, IsIn, IsNumber, IsOptional, IsString, MinLength, Validate, ValidateIf, ValidateNested
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsDefined,
IsIn,
IsNumber,
IsOptional,
IsString,
MinLength,
Validate,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto';
import { allProviders, type AllProvidersSettings, EmptySettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings';
import {
allProviders,
type AllProvidersSettings,
EmptySettings,
} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings';
import { ValidContent } from '@gitroom/helpers/utils/valid.images';
export class Integration {
@ -22,6 +38,10 @@ export class PostContent {
@IsString()
id: string;
@IsOptional()
@IsNumber()
delay: number;
@IsArray()
@Type(() => MediaDto)
@ValidateNested({ each: true })