Feat add actors to meilisearch (#1048)
* initial commit * update api docs * adds search token versioning * make id filterable * isLocal -> is_local * add iri to actor index * update gitignore * Merge remote-tracking branch 'origin/main' into feat-add-actors-to-meilisearch * fix sharing for newly discovered actors * fix ActorSearchResult type --------- Co-authored-by: Christian Beutel <>
This commit is contained in:
@@ -69,7 +69,7 @@ func GetActorByHandle(app core.App, ctx context.Context, handle string, includeF
|
||||
if domain != "" {
|
||||
filter += "domain={:domain}"
|
||||
} else {
|
||||
filter += "isLocal=true"
|
||||
filter += "is_local=true"
|
||||
}
|
||||
|
||||
var dbActor *core.Record
|
||||
@@ -81,7 +81,7 @@ func GetActorByHandle(app core.App, ctx context.Context, handle string, includeF
|
||||
}
|
||||
|
||||
dbActor = core.NewRecord(collection)
|
||||
dbActor.Set("isLocal", false)
|
||||
dbActor.Set("is_local", false)
|
||||
iri, err := iriFromHandle(ctx, domain, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -105,7 +105,7 @@ func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows
|
||||
}
|
||||
|
||||
dbActor = core.NewRecord(collection)
|
||||
dbActor.Set("isLocal", false)
|
||||
dbActor.Set("is_local", false)
|
||||
dbActor.Set("iri", iri)
|
||||
|
||||
} else if err != nil {
|
||||
@@ -167,7 +167,7 @@ func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, incl
|
||||
}
|
||||
|
||||
private := false
|
||||
if dbActor.GetBool("isLocal") {
|
||||
if dbActor.GetBool("is_local") {
|
||||
user, err := app.FindRecordById("users", dbActor.GetString("user"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -132,7 +132,7 @@ func processTrailAnnounceActivity(app core.App, actor *core.Record, activity pub
|
||||
}
|
||||
|
||||
var trail *core.Record
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
trail, err = util.TrailFromActivity(activity, app, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -210,7 +210,7 @@ func processListAnnounceActivity(app core.App, actor *core.Record, activity pub.
|
||||
}
|
||||
|
||||
var list *core.Record
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
list, err = util.ListFromActivity(activity, app, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -531,7 +531,7 @@ func processCreateOrUpdateCommentActivity(activity pub.Activity, app core.App, a
|
||||
}
|
||||
|
||||
// no need to do anything else if the actor is local
|
||||
if actor.GetBool("isLocal") {
|
||||
if actor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -645,7 +645,7 @@ func processCreateOrUpdateSummitLogActivity(activity pub.Activity, app core.App,
|
||||
}
|
||||
}
|
||||
// no need to do anything else if the actor is local
|
||||
if actor.GetBool("isLocal") {
|
||||
if actor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ func CreateCommentDeleteActivity(app core.App, client meilisearch.ServiceManager
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func CreateCommentDeleteActivity(app core.App, client meilisearch.ServiceManager
|
||||
return err
|
||||
}
|
||||
|
||||
if commentTrailAuthor.GetBool("isLocal") {
|
||||
if commentTrailAuthor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func CreateSummitLogDeleteActivity(app core.App, r *core.Record) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func CreateListDeleteActivity(app core.App, r *core.Record) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ func CreateListDeleteActivity(app core.App, r *core.Record) error {
|
||||
|
||||
func ProcessDeleteActivity(app core.App, actor *core.Record, activity pub.Activity) error {
|
||||
// no need to do anything if the actor is local
|
||||
if actor.GetBool("isLocal") {
|
||||
if actor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func ProcessFollowActivity(app core.App, actor *core.Record, activity pub.Activi
|
||||
// a remote actor has requested the follow
|
||||
// this means we have not yet created a follow entry in our db
|
||||
// we accept it immediately
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
followCollection, err := app.FindCollectionByNameOrId("follows")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -80,7 +80,7 @@ func ProcessLikeActivity(app core.App, actor *core.Record, activity pub.Activity
|
||||
return err
|
||||
}
|
||||
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
trailLikeCollection, err := app.FindCollectionByNameOrId("trail_like")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -140,7 +140,7 @@ func ProcessUndoActivity(app core.App, actor *core.Record, activity pub.Activity
|
||||
|
||||
func processUnfollowActivity(app core.App, actor *core.Record, activity pub.Activity) error {
|
||||
// this was a local follow
|
||||
if actor.GetBool("isLocal") {
|
||||
if actor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func processUnfollowActivity(app core.App, actor *core.Record, activity pub.Acti
|
||||
}
|
||||
|
||||
func processUnlikeActivity(app core.App, actor *core.Record, activity pub.Activity) error {
|
||||
if actor.GetBool("isLocal") {
|
||||
if actor.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
48
db/hooks/activitypub_actor.go
Normal file
48
db/hooks/activitypub_actor.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"pocketbase/util"
|
||||
"time"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return util.IndexActors([]*core.Record{e.Record}, client)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return util.UpdateActor(e.Record, client)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteActorHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
task, err := client.Index("actors").DeleteDocument(e.Record.Id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interval := 500 * time.Millisecond
|
||||
_, err = client.WaitForTask(task.TaskUID, interval)
|
||||
if err != nil {
|
||||
log.Fatalf("Error waiting for task completion: %v", err)
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func CreateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEve
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
// this happens if someone fetches a remote list
|
||||
// we create a stub list record for later reference
|
||||
// no need to create an activity for that
|
||||
@@ -75,7 +75,7 @@ func UpdateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEve
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
// this happens if someone fetches a remote list
|
||||
// we create a stub list record for later reference
|
||||
// no need to create an activity for that
|
||||
|
||||
@@ -51,7 +51,7 @@ func CreateTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.Reco
|
||||
return err
|
||||
}
|
||||
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
// this happens if someone likes a remote trail
|
||||
// we create a local copy
|
||||
// no need to create an activity for that
|
||||
@@ -107,7 +107,7 @@ func DeleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.Reco
|
||||
return err
|
||||
}
|
||||
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
// this happens if someone likes a remote trail
|
||||
// we create a local copy
|
||||
// no need to create an activity for that
|
||||
|
||||
@@ -47,7 +47,7 @@ func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv
|
||||
return err
|
||||
}
|
||||
|
||||
if !userActor.GetBool("isLocal") {
|
||||
if !userActor.GetBool("is_local") {
|
||||
// this happens if someone fetches a remote list
|
||||
// we create a stub list record for later reference
|
||||
// no need to create an activity for that
|
||||
@@ -92,7 +92,7 @@ func UpdateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEv
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userActor.GetBool("isLocal") {
|
||||
if !userActor.GetBool("is_local") {
|
||||
// this happens if someone fetches a remote trail
|
||||
// we create a stub trail record for later reference
|
||||
// no need to create an activity for that
|
||||
|
||||
37
db/main.go
37
db/main.go
@@ -90,6 +90,10 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
|
||||
app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client))
|
||||
app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client))
|
||||
|
||||
app.OnRecordAfterCreateSuccess("activitypub_actors").BindFunc(hooks.CreateActorHandler(client))
|
||||
app.OnRecordAfterUpdateSuccess("activitypub_actors").BindFunc(hooks.UpdateActorHandler(client))
|
||||
app.OnRecordAfterDeleteSuccess("activitypub_actors").BindFunc(hooks.DeleteActorHandler(client))
|
||||
|
||||
app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client))
|
||||
app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client))
|
||||
app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(client))
|
||||
@@ -315,6 +319,12 @@ func initMeilisearchConfig(client meilisearch.ServiceManager) {
|
||||
SortableAttributes: []string{"created", "name"},
|
||||
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
||||
},
|
||||
"actors": {
|
||||
SearchableAttributes: []string{"username", "preferred_username", "domain"},
|
||||
FilterableAttributes: []string{"id"},
|
||||
SortableAttributes: []string{},
|
||||
RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"},
|
||||
},
|
||||
}
|
||||
|
||||
for indexName, settings := range configs {
|
||||
@@ -404,5 +414,32 @@ func initMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) e
|
||||
page++
|
||||
}
|
||||
|
||||
// --- Actors ---
|
||||
if _, err := client.Index("actors").DeleteAllDocuments(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
page = 0
|
||||
for {
|
||||
actors := []*core.Record{}
|
||||
err := app.RecordQuery("activitypub_actors").
|
||||
Limit(pageSize).
|
||||
Offset(page * pageSize).
|
||||
All(&actors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(actors) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if err := util.IndexActors(actors, client); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to index actor page %d: %v", page, err))
|
||||
continue
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
52
db/migrations/1780734977_updated_activitypub_actors.go
Normal file
52
db/migrations/1780734977_updated_activitypub_actors.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(14, []byte(`{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool2193750486",
|
||||
"name": "is_local",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(14, []byte(`{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool2193750486",
|
||||
"name": "isLocal",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func RemoteTrailCommentsList(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
// Sync remote data first (Fetch + Save)
|
||||
if trail.GetString("iri") != "" && !trailAuthor.GetBool("isLocal") {
|
||||
if trail.GetString("iri") != "" && !trailAuthor.GetBool("is_local") {
|
||||
_ = syncRemoteComments(e, trail)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) e
|
||||
"trails": map[string]string{
|
||||
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userActor.Id,
|
||||
},
|
||||
"actors": map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1516,7 +1516,7 @@ func buildMergedCommentText(app core.App, comment *core.Record) string {
|
||||
if authorID := comment.GetString("author"); authorID != "" {
|
||||
if author, err := app.FindRecordById("activitypub_actors", authorID); err == nil {
|
||||
authorHandle = "@" + author.GetString("preferred_username")
|
||||
if !author.GetBool("isLocal") && author.GetString("domain") != "" {
|
||||
if !author.GetBool("is_local") && author.GetString("domain") != "" {
|
||||
authorHandle += "@" + author.GetString("domain")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func ActorFromUser(app core.App, u *core.Record) (*core.Record, error) {
|
||||
record.Set("outbox", id+"/outbox")
|
||||
record.Set("followers", id+"/followers")
|
||||
record.Set("following", id+"/following")
|
||||
record.Set("isLocal", true)
|
||||
record.Set("is_local", true)
|
||||
record.Set("public_key", string(pubPem))
|
||||
record.Set("private_key", privEncrypted)
|
||||
record.Set("user", u.Id)
|
||||
@@ -147,7 +147,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
// the local record; a remote actor must not be able to reference or attach
|
||||
// side effects (feeds/shares/notifications) to local content by id.
|
||||
if IsLocalIRI(iri) {
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
return nil, fmt.Errorf("refusing remote activity referencing local trail %q", iri)
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
|
||||
|
||||
// Own content must never be ingested as if it were remote (see TrailFromActivity).
|
||||
if IsLocalIRI(iri) {
|
||||
if !actor.GetBool("isLocal") {
|
||||
if !actor.GetBool("is_local") {
|
||||
return nil, fmt.Errorf("refusing remote activity referencing local list %q", iri)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) {
|
||||
func documentFromTrailRecord(r *core.Record, author *core.Record, includeShares bool) (map[string]interface{}, error) {
|
||||
photos := r.GetStringSlice("photos")
|
||||
thumbnail := ""
|
||||
if len(photos) > 0 {
|
||||
@@ -42,7 +42,7 @@ func documentFromTrailRecord(app core.App, r *core.Record, author *core.Record,
|
||||
bounds := getStoredBounds(r)
|
||||
|
||||
domain := ""
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
domain = author.GetString("domain")
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b
|
||||
totalDuration := 0.0
|
||||
trails := len(r.GetStringSlice("trails"))
|
||||
|
||||
if r.GetString("iri") != "" && !author.GetBool("isLocal") {
|
||||
if r.GetString("iri") != "" && !author.GetBool("is_local") {
|
||||
doc, err := documentFromRemoteRecord(r, "lists")
|
||||
if err == nil {
|
||||
totalElevationGain = doc["elevation_gain"].(float64)
|
||||
@@ -181,7 +181,7 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b
|
||||
}
|
||||
|
||||
domain := ""
|
||||
if !author.GetBool("isLocal") {
|
||||
if !author.GetBool("is_local") {
|
||||
domain = author.GetString("domain")
|
||||
}
|
||||
|
||||
@@ -222,6 +222,21 @@ func documentFromListRecord(r *core.Record, author *core.Record, includeShares b
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func documentFromActorRecord(r *core.Record) (map[string]any, error) {
|
||||
|
||||
document := map[string]any{
|
||||
"id": r.Id,
|
||||
"username": r.GetString("username"),
|
||||
"preferred_username": r.GetString("preferred_username"),
|
||||
"domain": r.GetString("domain"),
|
||||
"iri": r.GetString("iri"),
|
||||
"icon": r.GetString("icon"),
|
||||
"is_local": r.GetBool("is_local"),
|
||||
}
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func documentFromRemoteRecord(r *core.Record, index string) (map[string]any, error) {
|
||||
client := &http.Client{}
|
||||
|
||||
@@ -309,7 +324,7 @@ func IndexTrails(app core.App, trails []*core.Record, client meilisearch.Service
|
||||
|
||||
author := r.ExpandedOne("author")
|
||||
|
||||
doc, err := documentFromTrailRecord(app, r, author, true)
|
||||
doc, err := documentFromTrailRecord(r, author, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -334,7 +349,7 @@ func UpdateTrail(app core.App, r *core.Record, author *core.Record, client meili
|
||||
return fmt.Errorf("meilisearch update trail: failed to expand category: %v", errs)
|
||||
}
|
||||
|
||||
doc, err := documentFromTrailRecord(app, r, author, false)
|
||||
doc, err := documentFromTrailRecord(r, author, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -424,6 +439,37 @@ func UpdateList(app core.App, r *core.Record, author *core.Record, client meilis
|
||||
return nil
|
||||
}
|
||||
|
||||
func IndexActors(actors []*core.Record, client meilisearch.ServiceManager) error {
|
||||
documents := make([]map[string]any, len(actors))
|
||||
|
||||
for i, r := range actors {
|
||||
|
||||
doc, err := documentFromActorRecord(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
documents[i] = doc
|
||||
}
|
||||
if _, err := client.Index("actors").AddDocuments(documents, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateActor(r *core.Record, client meilisearch.ServiceManager) error {
|
||||
documents, err := documentFromActorRecord(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = client.Index("actors").UpdateDocuments(documents, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateListShares(listId string, shares []string, client meilisearch.ServiceManager) error {
|
||||
documents := []map[string]interface{}{
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ func SendNotification(app core.App, notification Notification, recipient *core.R
|
||||
if notification.Author == recipient.Id {
|
||||
return nil
|
||||
}
|
||||
if !recipient.GetBool("isLocal") {
|
||||
if !recipient.GetBool("is_local") {
|
||||
return nil
|
||||
}
|
||||
permissions, err := getNotificationPermissions(app, recipient.GetString("user"), notification.Type)
|
||||
|
||||
Reference in New Issue
Block a user