diff --git a/models/activities/federated_user_activity.go b/models/activities/federated_user_activity.go
index 1ff3a855d0..a9f509e8f7 100644
--- a/models/activities/federated_user_activity.go
+++ b/models/activities/federated_user_activity.go
@@ -82,7 +82,7 @@ func GetFollowingFeeds(ctx context.Context, actorID int64, opts GetFollowingFeed
sess = db.SetSessionPagination(sess, &opts)
actions := make([]*FederatedUserActivity, 0, opts.PageSize)
- count, err := sess.FindAndCount(&actions)
+ count, err := sess.Desc("`federated_user_activity`.created").FindAndCount(&actions)
if err != nil {
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index a6235c37ba..44f5da24cb 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -52,16 +52,17 @@ func NewFuncMap() template.FuncMap {
// -----------------------------------------------------------------
// html/template related functions
- "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
- "Eval": Eval,
- "TrustHTML": TrustHTML,
- "HTMLFormat": HTMLFormat,
- "HTMLEscape": HTMLEscape,
- "QueryEscape": QueryEscape,
- "JSEscape": JSEscapeSafe,
- "SanitizeHTML": SanitizeHTML,
- "URLJoin": util.URLJoin,
- "DotEscape": DotEscape,
+ "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
+ "Eval": Eval,
+ "TrustHTML": TrustHTML,
+ "HTMLFormat": HTMLFormat,
+ "HTMLEscape": HTMLEscape,
+ "QueryEscape": QueryEscape,
+ "JSEscape": JSEscapeSafe,
+ "SanitizeHTML": SanitizeHTML,
+ "SanitizeHTMLStrict": SanitizeHTMLStrict,
+ "URLJoin": util.URLJoin,
+ "DotEscape": DotEscape,
"PathEscape": url.PathEscape,
"PathEscapeSegments": util.PathEscapeSegments,
@@ -257,6 +258,10 @@ func SanitizeHTML(s string) template.HTML {
return template.HTML(markup.Sanitize(s))
}
+func SanitizeHTMLStrict(s string) template.HTML {
+ return template.HTML(markup.SanitizeDescription(s))
+}
+
func HTMLEscape(s any) template.HTML {
switch v := s.(type) {
case string:
diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json
index 3195d6f6f1..c0c73ec860 100644
--- a/options/locale_next/locale_en-US.json
+++ b/options/locale_next/locale_en-US.json
@@ -261,6 +261,12 @@
"settings.access_token.admin_disabled": "Administrative permissions are disabled.",
"error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s",
"avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels",
+ "user.activitypub_feed.feed": "Fediverse Feed",
+ "user.activitypub_feed.no_activity": "No fediverse activity",
+ "user.activitypub_feed.is_empty": "Your fediverse feed is empty.",
+ "user.activitypub_feed.hint": "This feed shows activities from fediverse accounts that you follow, as well as federated posts that mentioned you.",
+ "user.activitypub_feed.posted_on": "Posted on %[1]s",
+ "user.activitypub_feed.original_source": "Original source",
"user.ghost.tooltip": "This user has been deleted, or cannot be matched.",
"og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s",
"repo.commit.load_tags_failed": "Load tags failed because of internal error",
diff --git a/public/assets/img/svg/fediverse-small.svg b/public/assets/img/svg/fediverse-small.svg
new file mode 100644
index 0000000000..90f9a79ef5
--- /dev/null
+++ b/public/assets/img/svg/fediverse-small.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 2a3893f80e..ec3cb4a5d3 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -176,6 +176,28 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
} else {
ctx.Data["CardsNoneMsg"] = ctx.Tr("followers.outgoing.list.none", ctx.ContextUser.Name)
}
+ case "feed":
+ if setting.Federation.Enabled {
+ pagingNum = setting.UI.FeedPagingNum
+ var items []*activities_model.FederatedUserActivity
+ var count int64
+ if ctx.Doer != nil {
+ items, count, err = activities_model.GetFollowingFeeds(ctx,
+ ctx.Doer.ID,
+ activities_model.GetFollowingFeedsOptions{
+ ListOptions: db.ListOptions{
+ PageSize: pagingNum,
+ Page: page,
+ },
+ })
+ if err != nil {
+ ctx.ServerError("GetFollowingFeeds", err)
+ return
+ }
+ }
+ ctx.Data["FollowingFeeds"] = items
+ total = int(count)
+ }
case "activity":
// prepare heatmap data
if setting.Service.EnableUserHeatmap {
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl
index d31d25db46..8b4f661b57 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -19,6 +19,9 @@
{{end}}
+{{if and FederationEnabled .PageIsUserProfile .ContextUser .ContextUser.IsIndividual}}
+
+{{end}}
{{template "base/head_script" .}}
{{template "shared/user/mention_highlight" .}}
{{template "base/head_opengraph" .}}
diff --git a/templates/user/dashboard/ap_feed.tmpl b/templates/user/dashboard/ap_feed.tmpl
new file mode 100644
index 0000000000..1127026139
--- /dev/null
+++ b/templates/user/dashboard/ap_feed.tmpl
@@ -0,0 +1,26 @@
+
+ {{range .FollowingFeeds}}
+
+ {{if not (eq .Actor.ID 0)}}
+
+ {{ctx.AvatarUtils.Avatar . 48}}
+
+ {{end}}
+
+
+
+ {{.NoteContent | SanitizeHTMLStrict}}
+
+ {{if .NoteURL}}
+
+ {{end}}
+
+
+ {{end}}
+ {{template "base/paginate" .}}
+
diff --git a/templates/user/dashboard/ap_feed_guide.tmpl b/templates/user/dashboard/ap_feed_guide.tmpl
new file mode 100644
index 0000000000..a3a29abbed
--- /dev/null
+++ b/templates/user/dashboard/ap_feed_guide.tmpl
@@ -0,0 +1,6 @@
+
+ {{svg "octicon-people" 64 "tw-text-placeholder-text"}}
+
{{ctx.Locale.Tr "user.activitypub_feed.no_activity"}}
+
{{ctx.Locale.Tr "user.activitypub_feed.is_empty"}}
+
{{ctx.Locale.Tr "user.activitypub_feed.hint"}}
+
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl
index ea5d8052f4..cc306cc571 100644
--- a/templates/user/overview/header.tmpl
+++ b/templates/user/overview/header.tmpl
@@ -41,6 +41,11 @@
{{svg "octicon-rss"}} {{ctx.Locale.Tr "user.activity"}}
{{end}}
+ {{if and FederationEnabled (eq .SignedUserID .ContextUser.ID)}}
+
+ {{svg "fediverse-small"}} {{ctx.Locale.Tr "user.activitypub_feed.feed"}}
+
+ {{end}}
{{if not .DisableStars}}
{{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}}
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 30210a1775..6fd533767f 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -58,6 +58,14 @@
{{.ProfileReadme}}
{{end}}
+ {{else if and FederationEnabled (eq .TabName "feed")}}
+ {{if eq .SignedUserID .ContextUser.ID}}
+ {{if .FollowingFeeds}}
+ {{template "user/dashboard/ap_feed" .}}
+ {{else}}
+ {{template "user/dashboard/ap_feed_guide" .}}
+ {{end}}
+ {{end}}
{{else}}
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}
diff --git a/tests/integration/api_activitypub_person_inbox_useractivity_test.go b/tests/integration/api_activitypub_person_inbox_useractivity_test.go
index b1c28555e0..fa51050045 100644
--- a/tests/integration/api_activitypub_person_inbox_useractivity_test.go
+++ b/tests/integration/api_activitypub_person_inbox_useractivity_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "forgejo.org/models/activities"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
@@ -26,6 +27,78 @@ import (
"github.com/stretchr/testify/require"
)
+func TestActivityPubPersonInboxNoteFromDistant(t *testing.T) {
+ defer test.MockVariableValue(&setting.Federation.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ federation.Init()
+
+ mock := test.NewFederationServerMock()
+ federatedSrv := mock.DistantServer(t)
+ defer federatedSrv.Close()
+
+ onApplicationRun(t, func(t *testing.T, localUrl *url.URL) {
+ defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
+
+ distantURL := federatedSrv.URL
+ distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
+
+ localUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
+ localUser2Inbox := localUrl.JoinPath("/api/v1/activitypub/user-id/2/inbox").String()
+ localSession2 := loginUser(t, localUser2.LoginName)
+ localSecssion2Token := getTokenForLoggedInUser(t, localSession2, auth_model.AccessTokenScopeWriteUser)
+
+ // view own empty feed on web UI
+ feedPage := NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
+ feedPage.AssertElement(t, "#empty-ap-feed", true)
+
+ // follow (local follows distant)
+ req := NewRequestWithJSON(t, "POST",
+ "/api/v1/user/activitypub/follow",
+ &structs.APRemoteFollowOption{
+ Target: distantUser15URL,
+ }).
+ AddTokenAuth(localSecssion2Token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // send note (distant -> local)
+ distantNoteURL := fmt.Sprintf("%s/api/v1/activitypub/note/104", distantURL)
+
+ userActivity := fmt.Appendf(
+ []byte{},
+ `{"type":"Create",`+
+ `"actor":"%s",`+
+ `"to": ["https://www.w3.org/ns/activitystreams#Public"],`+
+ `"cc": ["%s"],`+
+ `"object": {"type":"Note","content":"The Content!",`+
+ `"url":"%s"}}`,
+ distantUser15URL,
+ localUser2URL,
+ distantNoteURL,
+ )
+
+ ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
+ cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
+ require.NoError(t, err)
+
+ c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
+ require.NoError(t, err)
+
+ resp, err := c.Post(userActivity, localUser2Inbox)
+ require.NoError(t, err)
+
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ // check whether user activity exists in local instance
+ unittest.AssertExistsAndLoadBean(t, &activities.FederatedUserActivity{NoteURL: distantNoteURL})
+
+ // view own non-empty feed on web UI
+ feedPage = NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
+ feedPage.AssertElement(t, "#empty-ap-feed", false)
+ })
+}
+
func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
diff --git a/tests/integration/federation_home_template_test.go b/tests/integration/federation_home_template_test.go
new file mode 100644
index 0000000000..806fe31b7b
--- /dev/null
+++ b/tests/integration/federation_home_template_test.go
@@ -0,0 +1,53 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/test"
+ "forgejo.org/routers"
+ "forgejo.org/tests"
+
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/net/html"
+)
+
+func getLinks(t *testing.T, url string) []*html.Node {
+ req := NewRequest(t, "GET", url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ links := htmlDoc.doc.Find("link[type=\"application/activity+json\"]").Nodes
+
+ return links
+}
+
+func TestFederationBaseHead(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("Federation disabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Federation.Enabled, false)()
+
+ links := getLinks(t, "/user1")
+ assert.Empty(t, links)
+ })
+
+ t.Run("Federation enabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Federation.Enabled, true)()
+
+ links := getLinks(t, "/user1")
+ assert.Len(t, links, 1)
+ })
+
+ t.Run("Organization", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Federation.Enabled, true)()
+
+ links := getLinks(t, "/org3")
+ assert.Empty(t, links)
+ })
+}
diff --git a/web_src/svg/fediverse-small.svg b/web_src/svg/fediverse-small.svg
new file mode 100644
index 0000000000..43baee480e
--- /dev/null
+++ b/web_src/svg/fediverse-small.svg
@@ -0,0 +1,31 @@
+
+
\ No newline at end of file