Skip to content

Commit

Permalink
feat(communities): announcements don't ask again (#717)
Browse files Browse the repository at this point in the history
show loading indicator for announcements when submitting, and hide when transaction is finished
  • Loading branch information
petersalomonsen authored Apr 3, 2024
1 parent f32400f commit cb87026
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 29 deletions.
51 changes: 50 additions & 1 deletion docs/developing/dontaskagain.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,55 @@ We would like to avoid that the user double clicks any submit or like button, so

After the transaction is complete, we would not want the user to think that a page reload is needed, so we still have to keep the loading indicator spinning, until VM cache invalidation occurs. Calling a view method on the changed data should ensure that we get fresh data, on cache invalidation, and then we can also remove the loading indicator.

## Limitations of BOS loader
An example of this is when posting community Announcements, that use the `devhub.entity.community.Compose` widget for editing and posting the announcement data. Here's how it references the widget:

```jsx
<Widget
src={"${REPL_DEVHUB}/widget/devhub.entity.community.Compose"}
props={{
onSubmit: (v) => {
setSubmittedAnnouncementData(v);
setCommunitySocialDB({ handle, data: v });
},
profileAccountId: `${handle}.community.${REPL_DEVHUB_CONTRACT}`,
isFinished: () => submittedAnnouncementData === null,
}}
/>
```

Notice the `onSubmit` handler where we call the `setSubmittedAnnouncementData` state method, and set it to the submitted value. Then we have the `isFinished` handler which will check if it is set back to `null`. `isFinished` is called by the Compose widget to see if it can remove the loading spinner indicator.

For setting the `submittedAnnouncementData` back to `null`, there is `useEffect` statement that triggers when it is set by the `onSubmit` handler:

```jsx
useEffect(() => {
if (submittedAnnouncementData) {
const checkForAnnouncementInSocialDB = () => {
Near.asyncView("${REPL_SOCIAL_CONTRACT}", "get", {
keys: [`${communityAccountId}/post/**`],
}).then((result) => {
try {
const submittedAnnouncementText = JSON.parse(
submittedAnnouncementData.post.main
).text;
const lastAnnouncementTextFromSocialDB = JSON.parse(
result[communityAccountId].post.main
).text;
if (submittedAnnouncementText === lastAnnouncementTextFromSocialDB) {
setSubmittedAnnouncementData(null);
return;
}
} catch (e) {}
setTimeout(() => checkForAnnouncementInSocialDB(), 1000);
});
};
checkForAnnouncementInSocialDB();
}
}, [submittedAnnouncementData]);
```

As you can see this is calling `social.get` to check if the latest post text matched the text we submitted. As long as it does not, we assume the transaction has not completed, and we have a `setTimeout` to try again one second later. If it matches we set the `submittedAnnouncementData` back to null, which will then also signal the Compose component to remove the loading spinner, and re-enable the submit button.

# Limitations of BOS loader

When developing locally, it is popular to use the BOS loader in combination with `flags` on https://near.org/flags pointing to your local development environment hosted by BOS loader. Unfortunately BOS is not able to detect your widget for the transaction confirmation, and so "Don't ask again" will not work. In order to test "Don't ask again" when working locally, you rather need to mock the responses of RPC calls to fetch your locally stored widgets. This can be done using Playwright, and you can see an example of such a mock in [bos-loader.js](../../playwright-tests/util/bos-loader.js). Using this approach `flags` is not used, but instead your playwright test browser, when it calls to RPC for the widget contents, will receive the contents served by your local BOS loader.
169 changes: 167 additions & 2 deletions playwright-tests/tests/announcements.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,166 @@
import { test, expect } from "@playwright/test";
import { modifySocialNearGetRPCResponsesInsteadOfGettingWidgetsFromBOSLoader } from "../util/bos-loader.js";
import { setDontAskAgainCacheValues } from "../util/cache.js";
import { pauseIfVideoRecording } from "../testUtils";
import {
mockTransactionSubmitRPCResponses,
decodeResultJSON,
encodeResultJSON,
} from "../util/transaction.js";

test.describe("Don't ask again enabled", () => {
test.use({
storageState:
"playwright-tests/storage-states/wallet-connected-with-devhub-access-key.json",
});

test.beforeEach(async ({ page }) => {
await modifySocialNearGetRPCResponsesInsteadOfGettingWidgetsFromBOSLoader(
page
);
});

test("Post announcement", async ({ page }) => {
test.setTimeout(60000);
await page.goto(
"/devhub.near/widget/app?page=community&handle=webassemblymusic"
);
const widgetSrc =
"devhub.near/widget/devhub.entity.community.Announcements";
await setDontAskAgainCacheValues({
page,
widgetSrc,
methodName: "set_community_socialdb",
contractId: "devhub.near",
});

const feedArea = await page.locator(".card > div > div > div:nth-child(2)");
await expect(feedArea).toBeVisible({ timeout: 10000 });
await expect(feedArea).toContainText("WebAssembly Music");
const composeTextarea = await page.locator(
`textarea[data-testid="compose-announcement"]`
);
await expect(composeTextarea).toBeVisible();
const announcementText =
"Announcements are live, though this is only an automated test!";
await composeTextarea.fill(announcementText);

const postButton = await page.locator(`button[data-testid="post-btn"]`);
await expect(postButton).toBeVisible();

await pauseIfVideoRecording(page);
const communityHandle = "webassemblymusic.community.devhub.near";
let is_transaction_completed = false;
await mockTransactionSubmitRPCResponses(
page,
async ({ route, request, transaction_completed, last_receiver_id }) => {
const postData = request.postDataJSON();
const args_base64 = postData.params?.args_base64;
if (transaction_completed && args_base64) {
is_transaction_completed = true;
const args = atob(args_base64);
if (
postData.params.account_id === "social.near" &&
postData.params.method_name === "get" &&
args === `{"keys":["${communityHandle}/post/**"]}`
) {
const response = await route.fetch();
const json = await response.json();

const resultObj = decodeResultJSON(json.result.result);
resultObj[communityHandle].post.main = JSON.stringify({
text: announcementText,
});
json.result.result = encodeResultJSON(resultObj);

await route.fulfill({ response, json });
return;
}
}
await route.continue();
}
);
let new_block_height;
let indexerQueryRetry = 0;
/*
// We can have this test when we can trust the indexer to be live, but it will fail if falling back to the social.index
await page.route(
"https://near-queryapi.api.pagoda.co/v1/graphql",
async (route) => {
const request = route.request();
const postData = request.postDataJSON();
if (is_transaction_completed) {
const response = await route.fetch();
const json = await response.json();
if (indexerQueryRetry < 4) {
indexerQueryRetry++;
} else {
if (postData.query.indexOf("IndexerQuery") > -1) {
new_block_height =
json.data.dataplatform_near_social_feed_posts[0].block_height +
10;
json.data.dataplatform_near_social_feed_posts[0].block_height =
new_block_height;
} else if (postData.query.indexOf("FeedQuery") > -1) {
json.data.dataplatform_near_social_feed_moderated_posts = [
{
account_id: "webassemblymusic.community.devhub.near",
block_height: new_block_height,
block_timestamp: new Date().getTime() * 1_000_000,
content:
'{"type":"md","text":"Announcements are live, though this is only an automated test"}',
receipt_id: "FeVQfzsNa2mCHumgPwv4CHkVDaRWCfPGEAev4iAh5CRY",
accounts_liked: [],
comments: [],
},
];
json.data.dataplatform_near_social_feed_moderated_posts_aggregate =
{
aggregate: { count: 1 },
};
}
}
await route.fulfill({ response, json });
}
}
);*/
await postButton.click();

const loadingIndicator = await page
.locator(".submit-post-loading-indicator")
.first();

await expect(loadingIndicator).toBeVisible();

await expect(postButton).toBeDisabled();

await expect(page.locator("div.modal-body code")).not.toBeVisible();

const transaction_toast = await page.getByText(
"Calling contract devhub.near with method set_community_socialdb"
);
await expect(transaction_toast).toBeVisible();

await expect(transaction_toast).not.toBeVisible();
await expect(loadingIndicator).not.toBeVisible();
await expect(postButton).toBeEnabled();
await expect(composeTextarea).toBeEmpty();

/*
// We can have this test when we can trust the indexer to be live, but it will fail if falling back to the social.index
const firstPost = await page.locator(".post").first();
await firstPost.scrollIntoViewIfNeeded();
await expect(firstPost).toContainText(
"Announcements are live, though this is only an automated test",
{ timeout: 10000 }
);
*/
await pauseIfVideoRecording(page);
});
});
test.describe("Non authenticated user's wallet is connected", () => {
test.use({
storageState: "playwright-tests/storage-states/wallet-connected.json",
Expand Down Expand Up @@ -93,8 +254,12 @@ test.describe("Admin wallet is connected", () => {
await page.goto(
"/devhub.near/widget/app?page=community&handle=webassemblymusic"
);
const postLocator = page.locator(".post").first();
await postLocator.focus();

const posts_section = await page.locator(".card").nth(1);
await posts_section.scrollIntoViewIfNeeded();
await expect(await posts_section).toContainText("WebAssembly Music", {
timeout: 10000,
});
});

// SKIPPING
Expand Down
1 change: 0 additions & 1 deletion playwright-tests/util/bos-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export async function modifySocialNearGetRPCResponsesInsteadOfGettingWidgetsFrom
const json = await response.json();

if (devComponents[social_get_key]) {
console.log("using local dev widget", social_get_key);
const social_get_key_parts = social_get_key.split("/");
const devWidget = {};
devWidget[social_get_key_parts[0]] = { widget: {} };
Expand Down
2 changes: 1 addition & 1 deletion scripts/deploy_preview_environment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
echo "This is an example script for deploying a preview environment. Please adjust with your own accounts for widget and contract"

echo "Building preview"
npm run build:preview -- -a devgovgigs.petersalomonsen.near -c truedove38.near
npm run build:preview -- -a devgovgigs.petersalomonsen.near -c devhub.near

echo "Deploying"
(cd build && bos components deploy devgovgigs.petersalomonsen.near sign-as devgovgigs.petersalomonsen.near network-config mainnet sign-with-access-key-file /home/codespace/devgovgigs.petersalomonsen.near.json send)
2 changes: 2 additions & 0 deletions src/devhub/components/organism/Feed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ return (
key: "main",
options: {
limit: 10,
subscribe: props.onNewUnseenPosts ? true : false,
order: props.sort ? props.sort : "desc",
accountId: filteredAccountIds,
},
Expand Down Expand Up @@ -121,6 +122,7 @@ return (
filteredAccountIds: filteredAccountIds,
setPostExists: setPostExists,
showFlagAccountFeature: showFlagAccountFeature,
onNewUnseenPosts: props.onNewUnseenPosts,
sort: props.sort,
}}
/>
Expand Down
30 changes: 17 additions & 13 deletions src/devhub/components/organism/Feed/NearQueryApi.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,6 @@ const loadMorePosts = (isUpdate) => {
});
};

const displayNewPosts = () => {
if (newUnseenPosts.length > 0) {
stopFeedUpdates();
const initialQueryTime = newUnseenPosts[0].block_timestamp + 1000; // timestamp is getting rounded by 3 digits
const newTotalCount = postsData.postsCountLeft + newUnseenPosts.length;
setPostsData({
posts: [...newUnseenPosts, ...postsData.posts],
postsCountLeft: newTotalCount,
});
setNewUnseenPosts([]);
setInitialQueryTime(initialQueryTime);
}
};
const startFeedUpdates = () => {
if (initialQueryTime === null) return;

Expand Down Expand Up @@ -243,6 +230,23 @@ useEffect(() => {
}
}, [initialQueryTime]);

useEffect(() => {
if (newUnseenPosts && newUnseenPosts.length > 0) {
stopFeedUpdates();
const initialQueryTime = newUnseenPosts[0].block_timestamp + 1000; // timestamp is getting rounded by 3 digits
const newTotalCount = postsData.postsCountLeft + newUnseenPosts.length;
setPostsData({
posts: [...newUnseenPosts, ...postsData.posts],
postsCountLeft: newTotalCount,
});
if (props.onNewUnseenPosts) {
props.onNewUnseenPosts(newUnseenPosts);
}
setNewUnseenPosts([]);
setInitialQueryTime(initialQueryTime);
}
}, [newUnseenPosts]);

const hasMore =
postsData.postsCountLeft !== postsData.posts.length &&
postsData.posts.length > 0;
Expand Down
54 changes: 50 additions & 4 deletions src/devhub/entity/community/Announcements.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,48 @@ setCommunitySocialDB = setCommunitySocialDB || (() => <></>);

const communityData = getCommunity({ handle });
const [postsExists, setPostExists] = useState(false);
const [newUnseenPosts, setNewUnseenPosts] = useState([]);
const [lastQueryRequestTimestamp, setLastQueryRequestTimestamp] = useState(
new Date().getTime()
);
const [submittedAnnouncementData, setSubmittedAnnouncementData] =
useState(null);
const communityAccountId = `${handle}.community.${REPL_DEVHUB_CONTRACT}`;

let checkIndexerInterval;
const onNewUnseenPosts = (newUnseenPosts) => {
if (newUnseenPosts.length > 0) {
clearInterval(checkIndexerInterval);
}
};

useEffect(() => {
if (submittedAnnouncementData) {
const checkForAnnouncementInSocialDB = () => {
Near.asyncView("${REPL_SOCIAL_CONTRACT}", "get", {
keys: [`${communityAccountId}/post/**`],
}).then((result) => {
try {
const submittedAnnouncementText = JSON.parse(
submittedAnnouncementData.post.main
).text;
const lastAnnouncementTextFromSocialDB = JSON.parse(
result[communityAccountId].post.main
).text;
if (submittedAnnouncementText === lastAnnouncementTextFromSocialDB) {
setSubmittedAnnouncementData(null);
checkIndexerInterval = setInterval(() => {
setLastQueryRequestTimestamp(new Date().getTime());
}, 500);
return;
}
} catch (e) {}
setTimeout(() => checkForAnnouncementInSocialDB(), 1000);
});
};
checkForAnnouncementInSocialDB();
}
}, [submittedAnnouncementData]);

const MainContent = styled.div`
padding-left: 2rem;
Expand Down Expand Up @@ -83,8 +125,12 @@ return (
<Widget
src={"${REPL_DEVHUB}/widget/devhub.entity.community.Compose"}
props={{
onSubmit: (v) => setCommunitySocialDB({ handle, data: v }),
onSubmit: (v) => {
setSubmittedAnnouncementData(v);
setCommunitySocialDB({ handle, data: v });
},
profileAccountId: `${handle}.community.${REPL_DEVHUB_CONTRACT}`,
isFinished: () => submittedAnnouncementData === null,
}}
/>
</div>
Expand Down Expand Up @@ -124,12 +170,12 @@ return (
src="${REPL_DEVHUB}/widget/devhub.components.organism.Feed"
props={{
showFlagAccountFeature: true,
filteredAccountIds: [
`${handle}.community.${REPL_DEVHUB_CONTRACT}`,
],
filteredAccountIds: [communityAccountId],
sort: sort,
setPostExists: setPostExists,
showFlagAccountFeature: true,
lastQueryRequestTimestamp,
onNewUnseenPosts,
}}
/>
</div>
Expand Down
Loading

0 comments on commit cb87026

Please sign in to comment.