feat(federation): add feat tab to show federated notes

This commit is contained in:
famfo 2025-12-09 16:39:45 +01:00
parent 25f250678f
commit 1fb17bd48e
No known key found for this signature in database
13 changed files with 250 additions and 11 deletions

View file

@ -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)
}

View file

@ -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:

View file

@ -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",

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6.35 6.35" class="svg fediverse-small" width="16" height="16" aria-hidden="true"><path d="M3.59 1.16a.204.204 0 0 0-.24.16.204.204 0 0 0 .158.24 1.63 1.63 0 0 1 .832.45 1.64 1.64 0 0 1 .353 1.806.204.204 0 0 0 .11.268.204.204 0 0 0 .265-.11A2.055 2.055 0 0 0 3.59 1.16M1.547 2.266a.204.204 0 0 0-.266.109 2.054 2.054 0 0 0 1.48 2.814.204.204 0 0 0 .241-.158.204.204 0 0 0-.16-.242 1.63 1.63 0 0 1-.832-.45 1.64 1.64 0 0 1-.483-1.163c0-.228.046-.446.13-.643a.204.204 0 0 0-.11-.267" style="stroke-linecap:round" transform="matrix(1.28704 0 0 1.31102 -.911 -.987)"/><path d="M1.72.264C1.065.264.53.802.53 1.456c0 .655.535 1.19 1.19 1.19s1.19-.535 1.19-1.19S2.373.264 1.72.264m0 .53c.368 0 .661.294.661.662a.66.66 0 0 1-.661.662.66.66 0 0 1-.662-.662c0-.368.293-.661.662-.661M4.63 3.705c-.654 0-1.19.535-1.19 1.19s.536 1.19 1.19 1.19c.655 0 1.19-.536 1.19-1.19 0-.655-.535-1.19-1.19-1.19m0 .527c.37 0 .661.294.661.663a.657.657 0 0 1-.66.662.66.66 0 0 1-.662-.662c0-.369.293-.663.662-.663"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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 {

View file

@ -19,6 +19,9 @@
{{end}}
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
{{if and FederationEnabled .PageIsUserProfile .ContextUser .ContextUser.IsIndividual}}
<link rel="alternate" type="application/activity+json" href="{{.ContextUser.APActorID}}">
{{end}}
{{template "base/head_script" .}}
{{template "shared/user/mention_highlight" .}}
{{template "base/head_opengraph" .}}

View file

@ -0,0 +1,26 @@
<div id="activity-feed" class="flex-list">
{{range .FollowingFeeds}}
<div class="flex-item">
{{if not (eq .Actor.ID 0)}}
<div class="flex-item-leading">
{{ctx.AvatarUtils.Avatar . 48}}
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
<a class="text muted" href="{{.ActorURI}}">{{.Actor.Name}}</a>
</div>
<div class="render-content markup">
{{.NoteContent | SanitizeHTMLStrict}}
</div>
{{if .NoteURL}}
<div class="flex-item-footer">
<span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.activitypub_feed.posted_on" (DateUtils.TimeSince .Created)}}</span>
<a class="flex-text-inline" href="{{.NoteURL}}">{{svg "octicon-link-external"}}{{ctx.Locale.Tr "user.activitypub_feed.original_source"}}</a>
</div>
{{end}}
</div>
</div>
{{end}}
{{template "base/paginate" .}}
</div>

View file

@ -0,0 +1,6 @@
<div id="empty-ap-feed" class="tw-text-center tw-p-8">
{{svg "octicon-people" 64 "tw-text-placeholder-text"}}
<h2>{{ctx.Locale.Tr "user.activitypub_feed.no_activity"}}</h2>
<p class="help">{{ctx.Locale.Tr "user.activitypub_feed.is_empty"}}</p>
<p class="help">{{ctx.Locale.Tr "user.activitypub_feed.hint"}}</p>
</div>

View file

@ -41,6 +41,11 @@
{{svg "octicon-rss"}} {{ctx.Locale.Tr "user.activity"}}
</a>
{{end}}
{{if and FederationEnabled (eq .SignedUserID .ContextUser.ID)}}
<a class="{{if eq .TabName "feed"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=feed">
{{svg "fediverse-small"}} {{ctx.Locale.Tr "user.activitypub_feed.feed"}}
</a>
{{end}}
{{if not .DisableStars}}
<a class="{{if eq .TabName "stars"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=stars">
{{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}}

View file

@ -58,6 +58,14 @@
{{.ProfileReadme}}
{{end}}
</div>
{{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" .}}

View file

@ -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())()

View file

@ -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)
})
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
viewBox="0 0 6.3499999 6.35"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<metadata
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"
>
<rdf:RDF>
<cc:Work rdf:about="https://codeberg.org/forgejo/forgejo/src/web_src/svg/fediverse-small.svg">
<dc:title>Forgejo small Fediverse icon</dc:title>
<cc:attributionName>The Forgejo Authors</cc:attributionName>
<cc:license>MIT</cc:license>
</cc:Work>
</rdf:RDF>
</metadata>
<path
style="stroke-linecap:round"
d="M 3.5898438,1.1601562 A 0.203686,0.203686 0 0 0 3.3496094,1.3203125 0.203686,0.203686 0 0 0 3.5078125,1.5605469 c 0.1072344,0.021943 0.2100638,0.054028 0.3085938,0.095703 0.1970601,0.08335 0.3741719,0.2042501 0.5234374,0.3535156 0.2985315,0.2985315 0.4843751,0.7096169 0.484375,1.1660157 0,0.2281995 -0.04751,0.4435652 -0.1308593,0.640625 a 0.203686,0.203686 0 0 0 0.109375,0.2675781 0.203686,0.203686 0 0 0 0.265625,-0.109375 C 5.1724517,3.7285087 5.2304688,3.4590205 5.2304687,3.1757813 5.2304687,2.6093024 5.0006974,2.0924942 4.6289062,1.7207031 4.4430106,1.5348075 4.2207091,1.3853417 3.9746094,1.28125 3.8515589,1.2292038 3.7237416,1.1875557 3.5898438,1.1601562 Z M 1.546875,2.265625 A 0.203686,0.203686 0 0 0 1.28125,2.375 C 1.1771594,2.6210999 1.1191406,2.8925415 1.1191406,3.1757813 c 0,0.5664787 0.2297714,1.0813338 0.6015625,1.4531249 0.1858956,0.1858957 0.408197,0.3353614 0.6542969,0.4394532 0.1230501,0.052046 0.2528205,0.093694 0.3867188,0.1210937 A 0.203686,0.203686 0 0 0 3.0019531,5.03125 0.203686,0.203686 0 0 0 2.8417969,4.7890625 C 2.7345627,4.7671193 2.6317332,4.7350342 2.5332031,4.6933594 2.3361428,4.6100096 2.1590312,4.4891093 2.0097656,4.3398437 1.7112341,4.0413123 1.5273438,3.6321799 1.5273437,3.1757813 c 0,-0.2281991 0.045557,-0.4455169 0.1289063,-0.6425782 A 0.203686,0.203686 0 0 0 1.546875,2.265625 Z"
transform="matrix(1.2870397,0,0,1.3110209,-0.91132678,-0.98749822)" />
<path
d="m 1.984375,0.921875 c -0.467452,2e-8 -0.8496094,0.3841105 -0.8496094,0.8515625 0,0.467452 0.3821574,0.8496094 0.8496094,0.8496094 0.467452,0 0.8496094,-0.3821574 0.8496094,-0.8496094 0,-0.467452 -0.3821574,-0.85156248 -0.8496094,-0.8515625 z m 0,0.3789062 c 0.2631744,10e-8 0.4726562,0.2094819 0.4726563,0.4726563 -1e-7,0.2631744 -0.2094819,0.4726562 -0.4726563,0.4726563 -0.2631744,-1e-7 -0.4726562,-0.2094819 -0.4726563,-0.4726563 10e-8,-0.2631744 0.2094819,-0.4726562 0.4726563,-0.4726563 z"
transform="matrix(1.3999375,0,0,1.4000066,-1.0582509,-1.0265909)" />
<path
d="m 4.6308594,3.7050781 c -0.6544307,10e-8 -1.1914062,0.5350214 -1.1914063,1.1894531 10e-8,0.6544318 0.5369756,1.1914062 1.1914063,1.1914063 0.6544307,0 1.1894531,-0.5369745 1.1894531,-1.1914063 0,-0.6544317 -0.5350224,-1.1894531 -1.1894531,-1.1894531 z m 0,0.5273438 c 0.3684464,0 0.6601562,0.2936593 0.6601562,0.6621093 0,0.3684501 -0.2917098,0.6621094 -0.6601562,0.6621094 -0.3684464,0 -0.6621094,-0.2936593 -0.6621094,-0.6621094 0,-0.36845 0.293663,-0.6621093 0.6621094,-0.6621093 z" />
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB