Skip to content

Commit

Permalink
Add Article support on a per-category basis
Browse files Browse the repository at this point in the history
An Article is for when you don't want to restrict the length of the content being federated (i.e. you want to federate entire posts). Note that Mastodon currently converts the content of Article types into a link, however platforms like Lemmy will show the full content. See further mastodon/mastodon#24079
  • Loading branch information
angusmcleod committed Jul 18, 2023
1 parent 1c63290 commit 4d3a42b
Show file tree
Hide file tree
Showing 15 changed files with 249 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,7 @@ def _model
end

def ensure_ap_type
if !self.ap_type
self.ap_type = case _model.class.name
when 'Category' then AP::Actor::Group.type
when 'Post' then AP::Object::Note.type
end
end
self.ap_type = _model.activity_pub_default_object_type if !self.ap_type

unless ap
self.errors.add(
Expand All @@ -42,6 +37,8 @@ def ensure_ap_type

raise ActiveRecord::RecordInvalid
end

self.ap_type = ap.type
end

def ensure_ap_key
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

class DiscourseActivityPub::AP::Object::ArticleSerializer < DiscourseActivityPub::AP::ObjectSerializer
attributes :content,
:url,
:updated

def include_content?
object.content.present? && !deleted?
end

def include_url?
object.stored.local? && !deleted?
end

def include_updated?
object.updated.present?
end

def deleted?
!object.stored.model || object.stored.model.trashed?
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
import I18n from "I18n";
import { computed } from "@ember/object";

export default DropdownSelectBoxComponent.extend({
classNames: ["activity-pub-post-object-type-dropdown"],

content: computed(function () {
return [
{
id: "Note",
label: I18n.t("discourse_activity_pub.post_object_type.note.label"),
title: I18n.t(
"discourse_activity_pub.post_object_type.note.description"
),
},
{
id: "Article",
label: I18n.t("discourse_activity_pub.post_object_type.article.label"),
title: I18n.t(
"discourse_activity_pub.post_object_type.article.description"
),
},
];
}),

actions: {
onChange(value) {
this.attrs.onChange && this.attrs.onChange(value);
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,15 @@
<div class="activity-pub-setting-description">
{{i18n 'category.discourse_activity_pub.default_visibility_description'}}
</div>
</section>

<section class='field activity-pub-setting activity-pub-post-object-type'>
<label for="activity-pub-post-object-type">{{i18n 'category.discourse_activity_pub.post_object_type'}}</label>
<ActivityPubPostObjectTypeDropdown
@value={{this.category.custom_fields.activity_pub_post_object_type}}
@onChange={{action (mut this.category.custom_fields.activity_pub_post_object_type)}}
/>
<div class="activity-pub-setting-description">
{{i18n 'category.discourse_activity_pub.post_object_type_description'}}
</div>
</section>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const customFieldDefaults = {
activity_pub_default_visibility: "public",
activity_pub_post_object_type: "note",
};

export default {
Expand Down
3 changes: 2 additions & 1 deletion assets/stylesheets/common/common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@
}
}

.activity-pub-visibility-dropdown {
.activity-pub-visibility-dropdown,
.activity-pub-post-object-type-dropdown {
.select-kit-header {
padding: 4px 6px;
}
Expand Down
13 changes: 11 additions & 2 deletions config/locales/client.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ en:
name_description: Used as a display name on some services (e.g. Mastodon).
default_visibility: Default visibility for posts published via ActivityPub in this category.
default_visibility_description: If the ActivityPub status is not shown in composer, all posts will be published via ActivityPub with this visibility.
post_object_type: ActivityPub type to publish posts in this category as.
post_object_type_description: Use Note for short content (e.g. Mastodon) and Article for long content.
discourse_activity_pub:
status:
title:
Expand All @@ -36,10 +38,17 @@ en:
visibility:
private:
label: Followers Only
description: ActivityPub Note is addressed to followers
description: ActivityPub object is addressed to followers
public:
label: Public
description: ActivityPub Note is publicly addressed
description: ActivityPub object is publicly addressed
post_object_type:
note:
label: Note
description: Best for microblogging platforms (e.g. Mastodon).
article:
label: Article
description: Best for longform platforms (e.g. Lemmy).
post:
discourse_activity_pub:
title:
Expand Down
6 changes: 3 additions & 3 deletions lib/discourse_activity_pub/ap/actor/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ def can_perform_activity
{
accept: [:follow],
reject: [:follow],
create: [:note],
delete: [:note],
update: [:note]
create: [:note, :article],
delete: [:note, :article],
update: [:note, :article]
}
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/discourse_activity_pub/ap/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def self.from_type(type)

def self.get_klass(type)
self.descendants.find do |klass|
klass.to_s.demodulize === type
klass.to_s.demodulize.downcase === type.downcase
end
end

Expand Down
25 changes: 25 additions & 0 deletions lib/discourse_activity_pub/ap/object/article.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module DiscourseActivityPub
module AP
class Object
class Article < Object

def type
"Article"
end

def content
stored&.content
end

def updated
stored&.updated_at.iso8601
end

def can_belong_to
%i(post)
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# TODO (future): PR discourse/discourse to support alternate excerpts

class DiscourseActivityPub::ExcerptParser < ExcerptParser
class DiscourseActivityPub::ContentParser < ExcerptParser
CUSTOM_NOTE_REGEX = /<\s*(div)[^>]*class\s*=\s*['"]note['"][^>]*>/

MARKDOWN_FEATURES = %w[
Expand Down Expand Up @@ -46,26 +46,34 @@ def self.cook(text, opts = {})
markdown_it_rules: MARKDOWN_IT_RULES,
)
)
doc = Nokogiri::HTML5.fragment(html)
scrubbed_html(doc)
scrubbed_html(html)
end

def self.scrubbed_html(doc)
def self.scrubbed_html(html)
doc = Nokogiri::HTML5.fragment(html)
scrubber = Loofah::Scrubber.new { |node| node.remove if node.name == "script" }
loofah_fragment = Loofah.html5_fragment(doc.to_html)
loofah_fragment.scrub!(scrubber).to_html
end

def self.get_content(post)
cooked = cook(post.raw, topic_id: post.topic_id, user_id: post.user_id)
max_length = SiteSetting.activity_pub_note_excerpt_maxlength
get_excerpt(cooked, max_length, post: post)
html = cook(post.raw, topic_id: post.topic_id, user_id: post.user_id)
type = post.activity_pub_object_type.downcase
return nil unless self.respond_to?("get_#{type}") && html
self.send("get_#{type}", html)
end

def self.get_article(html)
html
end

def self.get_excerpt(html, length, options)
html ||= ""
length = html.length if html.include?("note") && CUSTOM_NOTE_REGEX === html
me = self.new(length, options)
def self.get_note(html)
length = if html.include?("note") && CUSTOM_NOTE_REGEX === html
html.length
else
SiteSetting.activity_pub_note_excerpt_maxlength
end
me = self.new(length, {})
parser = Nokogiri::HTML::SAX::Parser.new(me)
catch(:done) { parser.parse(html) }
me.excerpt.strip
Expand Down
24 changes: 20 additions & 4 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
../lib/discourse_activity_pub/request.rb
../lib/discourse_activity_pub/webfinger.rb
../lib/discourse_activity_pub/username_validator.rb
../lib/discourse_activity_pub/excerpt_parser.rb
../lib/discourse_activity_pub/content_parser.rb
../lib/discourse_activity_pub/signature_parser.rb
../lib/discourse_activity_pub/delivery_failure_tracker.rb
../lib/discourse_activity_pub/ap.rb
Expand All @@ -36,6 +36,7 @@
../lib/discourse_activity_pub/ap/activity/update.rb
../lib/discourse_activity_pub/ap/activity/undo.rb
../lib/discourse_activity_pub/ap/object/note.rb
../lib/discourse_activity_pub/ap/object/article.rb
../lib/discourse_activity_pub/ap/collection.rb
../lib/discourse_activity_pub/ap/collection/ordered_collection.rb
../app/models/concerns/discourse_activity_pub/ap/identifier_validations.rb
Expand Down Expand Up @@ -72,6 +73,7 @@
../app/serializers/discourse_activity_pub/ap/actor/group_serializer.rb
../app/serializers/discourse_activity_pub/ap/actor/person_serializer.rb
../app/serializers/discourse_activity_pub/ap/object/note_serializer.rb
../app/serializers/discourse_activity_pub/ap/object/article_serializer.rb
../app/serializers/discourse_activity_pub/ap/collection_serializer.rb
../app/serializers/discourse_activity_pub/ap/collection/ordered_collection_serializer.rb
../app/serializers/discourse_activity_pub/webfinger_serializer.rb
Expand Down Expand Up @@ -110,6 +112,7 @@
register_category_custom_field_type("activity_pub_username", :string)
register_category_custom_field_type("activity_pub_name", :string)
register_category_custom_field_type("activity_pub_default_visibility", :string)
register_category_custom_field_type("activity_pub_post_object_type", :string)
add_to_class(:category, :activity_pub_url) do
"#{DiscourseActivityPub.base_url}#{self.url}"
end
Expand Down Expand Up @@ -155,6 +158,12 @@
add_to_class(:category, :activity_pub_default_visibility) do
custom_fields["activity_pub_default_visibility"] || DiscourseActivityPubActivity::DEFAULT_VISIBILITY
end
add_to_class(:category, :activity_pub_post_object_type) do
custom_fields["activity_pub_post_object_type"]
end
add_to_class(:category, :activity_pub_default_object_type) do
DiscourseActivityPub::AP::Actor::Group.type
end

add_model_callback(:category, :after_save) do
DiscourseActivityPubActor.ensure_for(self)
Expand Down Expand Up @@ -265,7 +274,7 @@
if custom_fields["activity_pub_content"].present?
custom_fields["activity_pub_content"]
else
DiscourseActivityPub::ExcerptParser.get_content(self)
DiscourseActivityPub::ContentParser.get_content(self)
end
end
add_to_class(:post, :activity_pub_actor) do
Expand Down Expand Up @@ -343,6 +352,13 @@

performing_activity
end
add_to_class(:post, :activity_pub_object_type) do
self.activity_pub_object&.ap_type || self.activity_pub_default_object_type
end
add_to_class(:post, :activity_pub_default_object_type) do
self.topic&.category&.activity_pub_post_object_type ||
DiscourseActivityPub::AP::Object::Note.type
end

add_to_serializer(:post, :activity_pub_enabled) do
object.activity_pub_enabled
Expand All @@ -359,7 +375,7 @@
if post.activity_pub_enabled
post.custom_fields[
"activity_pub_content"
] = DiscourseActivityPub::ExcerptParser.get_content(post)
] = DiscourseActivityPub::ContentParser.get_content(post)
end
end
on(:post_edited) do |post, topic_changed, post_revisor|
Expand All @@ -369,7 +385,7 @@
if post.activity_pub_enabled
post.custom_fields[
"activity_pub_content"
] = DiscourseActivityPub::ExcerptParser.get_content(post)
] = DiscourseActivityPub::ContentParser.get_content(post)
post.custom_fields[
"activity_pub_visibility"
] = post_opts[:activity_pub_visibility] ||
Expand Down
13 changes: 13 additions & 0 deletions spec/fabricators/discourse_activity_pub_object_fabricator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,16 @@
end
end
end

Fabricator(:discourse_activity_pub_object_article, from: :discourse_activity_pub_object) do
ap_type { DiscourseActivityPub::AP::Object::Article.type }
model { Fabricate(:post) }
local { true }

after_create do |object|
if object.model.respond_to?(:activity_pub_content)
object.content = object.model.activity_pub_content
object.save!
end
end
end
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# frozen_string_literal: true

RSpec.describe DiscourseActivityPub::ExcerptParser do
describe "#get_excerpt" do
RSpec.describe DiscourseActivityPub::ContentParser do
describe "#get_note" do
it "handles div note in short post" do
expect(described_class.get_excerpt("<div class='note'>hi</div> test", 100, {})).to eq("hi")
expect(described_class.get_note("<div class='note'>hi</div> test")).to eq("hi")
end

it "handles div note in long post" do
html = <<~HTML
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis quam nulla, feugiat venenatis elementum ut, imperdiet eu nisl. Vestibulum dictum luctus tortor, vel consequat lectus tristique non. Sed aliquam at eros et lacinia. Nam viverra libero at tortor semper fringilla non ut velit. Mauris dignissim sapien sed felis consequat, quis ullamcorper augue viverra. Donec elementum nisl ut leo viverra, vel consequat diam facilisis. Donec leo arcu, dictum vel vestibulum sit amet, maximus sed neque. Vestibulum blandit metus ante, sit amet porta lorem maximus id. Suspendisse sed lacus sapien. Nulla dui dui, dapibus vitae quam ut, elementum ultrices ipsum. In congue laoreet eleifend. Sed tincidunt consequat dolor, volutpat posuere arcu molestie a. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.</p>
<p>Pellentesque feugiat, elit ut aliquam fringilla, lectus eros rhoncus arcu, eget posuere ex velit ac purus. Morbi nec enim iaculis, lobortis lacus id, laoreet neque. Pellentesque et turpis a sapien tincidunt consequat quis a urna. Curabitur id ipsum vitae nisi dapibus tincidunt. Cras hendrerit nunc eget consectetur dapibus. Donec lacinia in sapien ac pellentesque. Phasellus at risus et lorem luctus pretium a eget leo. Nulla pellentesque metus libero, sit amet efficitur diam vehicula ac. Mauris ultrices erat non nulla volutpat tristique et sed arcu. </p><div class="note">hi</div><p></p>
HTML
expect(described_class.get_excerpt(html, 100, {})).to eq("hi")
expect(described_class.get_note(html)).to eq("hi")
end
end

Expand All @@ -38,5 +38,13 @@
post = Fabricate(:post, raw: content)
expect(described_class.get_content(post)).to eq(content)
end

context "with Article" do
it "returns all cooked content" do
Post.any_instance.stubs(:activity_pub_object_type).returns('Article')
post = Fabricate(:post_with_very_long_raw_content)
expect(described_class.get_content(post)).to eq(described_class.cook(post.raw))
end
end
end
end

0 comments on commit 4d3a42b

Please sign in to comment.