diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index a83d7f7333..24b0db2f1d 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -341,7 +341,6 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb // Action response for follow/unfollow user request func Action(ctx *context.Context) { var err error - var redirectViaJSON bool action := ctx.FormString("action") if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") { @@ -357,10 +356,8 @@ func Action(ctx *context.Context) { err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) case "block": err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) - redirectViaJSON = true case "unblock": err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) - redirectViaJSON = true } if err != nil { @@ -371,21 +368,15 @@ func Action(ctx *context.Context) { } if ctx.ContextUser.IsOrganization() { - ctx.Flash.Error(ctx.Tr("org.follow_blocked_user")) + ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"), true) } else { - ctx.Flash.Error(ctx.Tr("user.follow_blocked_user")) + ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"), true) } } - if redirectViaJSON { - ctx.JSON(http.StatusOK, map[string]any{ - "redirect": ctx.ContextUser.HomeLink(), - }) - return - } - if ctx.ContextUser.IsIndividual() { shared_user.PrepareContextForProfileBigAvatar(ctx) + ctx.Data["IsHTMX"] = true ctx.HTML(http.StatusOK, tplProfileBigAvatar) return } else if ctx.ContextUser.IsOrganization() { diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl index 760d3bfa2c..b2deab5c2d 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -1,20 +1,23 @@ {{if .Flash.ErrorMsg}} -
+

{{.Flash.ErrorMsg | SanitizeHTML}}

{{end}} {{if .Flash.SuccessMsg}} -
+

{{.Flash.SuccessMsg | SanitizeHTML}}

{{end}} {{if .Flash.InfoMsg}} -
+

{{.Flash.InfoMsg | SanitizeHTML}}

{{end}} {{if .Flash.WarningMsg}} -
+

{{.Flash.WarningMsg | SanitizeHTML}}

{{end}} +{{if and (not .Flash.ErrorMsg) (not .Flash.SuccessMsg) (not .Flash.InfoMsg) (not .Flash.WarningMsg) (not .IsHTMX)}} +
+{{end}} diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl index 3063aeacf7..6795eaed2c 100644 --- a/templates/shared/user/profile_big_avatar.tmpl +++ b/templates/shared/user/profile_big_avatar.tmpl @@ -1,4 +1,7 @@ -
+{{if .IsHTMX}} + {{template "base/alert" .}} +{{end}} +
{{if eq .SignedUserID .ContextUser.ID}} @@ -98,7 +101,7 @@ {{end}} {{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}} - -
  • +
  • {{if $.IsBlocked}} - {{else}} - {{end}} diff --git a/tests/e2e/profile_actions.test.e2e.js b/tests/e2e/profile_actions.test.e2e.js new file mode 100644 index 0000000000..20155b8df4 --- /dev/null +++ b/tests/e2e/profile_actions.test.e2e.js @@ -0,0 +1,41 @@ +// @ts-check +import {test, expect} from '@playwright/test'; +import {login_user, load_logged_in_context} from './utils_e2e.js'; + +test('Follow actions', async ({browser}, workerInfo) => { + await login_user(browser, workerInfo, 'user2'); + const context = await load_logged_in_context(browser, workerInfo, 'user2'); + const page = await context.newPage(); + + await page.goto('/user1'); + await page.waitForLoadState('networkidle'); + + // Check if following and then unfollowing works. + // This checks that the event listeners of + // the buttons aren't dissapearing. + const followButton = page.locator('.follow'); + await expect(followButton).toContainText('Follow'); + await followButton.click(); + await expect(followButton).toContainText('Unfollow'); + await followButton.click(); + await expect(followButton).toContainText('Follow'); + + // Simple block interaction. + await expect(page.locator('.block')).toContainText('Block'); + + await page.locator('.block').click(); + await expect(page.locator('#block-user')).toBeVisible(); + await page.locator('#block-user .ok').click(); + await expect(page.locator('.block')).toContainText('Unblock'); + await expect(page.locator('#block-user')).not.toBeVisible(); + + // Check that following the user yields in a error being shown. + await followButton.click(); + const flashMessage = page.locator('#flash-message'); + await expect(flashMessage).toBeVisible(); + await expect(flashMessage).toContainText('You cannot follow this user because you have blocked this user or this user has blocked you.'); + + // Unblock interaction. + await page.locator('.block').click(); + await expect(page.locator('.block')).toContainText('Block'); +}); diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go index 8f40ed13e8..f917d700da 100644 --- a/tests/integration/block_test.go +++ b/tests/integration/block_test.go @@ -34,15 +34,8 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) { "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), "action": "block", }) - resp := session.MakeRequest(t, req, http.StatusOK) + session.MakeRequest(t, req, http.StatusOK) - type redirect struct { - Redirect string `json:"redirect"` - } - - var respBody redirect - DecodeJSON(t, resp, &respBody) - assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect) assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) } @@ -303,11 +296,10 @@ func TestBlockActions(t *testing.T) { "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), "action": "follow", }) - session.MakeRequest(t, req, http.StatusOK) + resp := session.MakeRequest(t, req, http.StatusOK) - flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) - assert.NotNil(t, flashCookie) - assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.") // Assert it still doesn't exist. unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) @@ -323,11 +315,10 @@ func TestBlockActions(t *testing.T) { "_csrf": GetCSRF(t, session, "/"+doer.Name), "action": "follow", }) - session.MakeRequest(t, req, http.StatusOK) + resp := session.MakeRequest(t, req, http.StatusOK) - flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) - assert.NotNil(t, flashCookie) - assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.") unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) }) diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index e7db9b2336..5a304d96f5 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -295,11 +295,11 @@ async function linkAction(e) { export function initGlobalLinkActions() { function showDeletePopup(e) { e.preventDefault(); - const $this = $(this); + const $this = $(this || e.target); const dataArray = $this.data(); let filter = ''; - if (this.getAttribute('data-modal-id')) { - filter += `#${this.getAttribute('data-modal-id')}`; + if ($this[0].getAttribute('data-modal-id')) { + filter += `#${$this[0].getAttribute('data-modal-id')}`; } const $dialog = $(`.delete.modal${filter}`); @@ -317,6 +317,10 @@ export function initGlobalLinkActions() { $($this.data('form')).trigger('submit'); return; } + if ($this[0].getAttribute('hx-confirm')) { + e.detail.issueRequest(true); + return; + } const postData = new FormData(); for (const [key, value] of Object.entries(dataArray)) { if (key && key.startsWith('data')) { @@ -338,6 +342,19 @@ export function initGlobalLinkActions() { // Helpers. $('.delete-button').on('click', showDeletePopup); + + document.addEventListener('htmx:confirm', (e) => { + e.preventDefault(); + // htmx:confirm is triggered for every HTMX request, even those that don't + // have the `hx-confirm` attribute specified. To avoid opening modals for + // those elements, check if 'e.detail.question' is empty, which contains the + // value of the `hx-confirm` attribute. + if (!e.detail.question) { + e.detail.issueRequest(true); + } else { + showDeletePopup(e); + } + }); } function initGlobalShowModal() {