mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-09 08:54:50 -04:00
feat(federation): add feat tab to show federated notes
This commit is contained in:
parent
25f250678f
commit
1fb17bd48e
13 changed files with 250 additions and 11 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
public/assets/img/svg/fediverse-small.svg
generated
Normal file
1
public/assets/img/svg/fediverse-small.svg
generated
Normal 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 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" .}}
|
||||
|
|
|
|||
26
templates/user/dashboard/ap_feed.tmpl
Normal file
26
templates/user/dashboard/ap_feed.tmpl
Normal 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>
|
||||
6
templates/user/dashboard/ap_feed_guide.tmpl
Normal file
6
templates/user/dashboard/ap_feed_guide.tmpl
Normal 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>
|
||||
|
|
@ -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"}}
|
||||
|
|
|
|||
|
|
@ -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" .}}
|
||||
|
|
|
|||
|
|
@ -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())()
|
||||
|
|
|
|||
53
tests/integration/federation_home_template_test.go
Normal file
53
tests/integration/federation_home_template_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
31
web_src/svg/fediverse-small.svg
Normal file
31
web_src/svg/fediverse-small.svg
Normal 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 |
Loading…
Reference in a new issue