From 128987ededcbcdf73529d98a4f11c747b2bbe892 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Wed, 29 May 2024 01:34:33 +0200
Subject: [PATCH] Add support for `fediverse:creator` OpenGraph tag (#30398)

---
 .../api/v1/conversations_controller.rb        |  8 ++--
 .../features/status/components/card.jsx       | 32 +++++++++++---
 app/javascript/mastodon/locales/en.json       |  1 +
 .../styles/mastodon/components.scss           | 43 +++++++++++++++++++
 app/lib/link_details_extractor.rb             |  4 ++
 app/models/preview_card.rb                    |  2 +
 app/models/status.rb                          | 10 ++---
 .../rest/preview_card_serializer.rb           |  2 +
 app/services/fetch_link_card_service.rb       |  3 ++
 ..._add_author_account_id_to_preview_cards.rb | 10 +++++
 db/schema.rb                                  |  5 ++-
 11 files changed, 105 insertions(+), 15 deletions(-)
 create mode 100644 db/migrate/20240522041528_add_author_account_id_to_preview_cards.rb

diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
index a95c816e1..a29b90855 100644
--- a/app/controllers/api/v1/conversations_controller.rb
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -38,15 +38,15 @@ class Api::V1::ConversationsController < Api::BaseController
   def paginated_conversations
     AccountConversation.where(account: current_account)
                        .includes(
-                         account: :account_stat,
+                         account: [:account_stat, user: :role],
                          last_status: [
                            :media_attachments,
                            :status_stat,
                            :tags,
                            {
-                             preview_cards_status: :preview_card,
-                             active_mentions: [account: :account_stat],
-                             account: :account_stat,
+                             preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
+                             active_mentions: :account,
+                             account: [:account_stat, user: :role],
                            },
                          ]
                        )
diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx
index f47861f66..c2f5703b3 100644
--- a/app/javascript/mastodon/features/status/components/card.jsx
+++ b/app/javascript/mastodon/features/status/components/card.jsx
@@ -6,6 +6,8 @@ import { PureComponent } from 'react';
 import { FormattedMessage } from 'react-intl';
 
 import classNames from 'classnames';
+import { Link } from 'react-router-dom';
+
 
 import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -13,6 +15,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
 import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
 import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
+import { Avatar } from 'mastodon/components/avatar';
 import { Blurhash } from 'mastodon/components/blurhash';
 import { Icon }  from 'mastodon/components/icon';
 import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@@ -56,6 +59,20 @@ const addAutoPlay = html => {
   return html;
 };
 
+const MoreFromAuthor = ({ author }) => (
+  <div className='more-from-author'>
+    <svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
+      <use xlinkHref='#logo-symbol-icon' />
+    </svg>
+
+    <FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <Link to={`/@${author.get('acct')}`}><Avatar account={author} size={16} /> {author.get('display_name')}</Link> }} />
+  </div>
+);
+
+MoreFromAuthor.propTypes = {
+  author: ImmutablePropTypes.map,
+};
+
 export default class Card extends PureComponent {
 
   static propTypes = {
@@ -136,6 +153,7 @@ export default class Card extends PureComponent {
     const interactive = card.get('type') === 'video';
     const language    = card.get('language') || '';
     const largeImage  = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
+    const showAuthor  = !!card.get('author_account');
 
     const description = (
       <div className='status-card__content'>
@@ -146,7 +164,7 @@ export default class Card extends PureComponent {
 
         <strong className='status-card__title' title={card.get('title')} lang={language}>{card.get('title')}</strong>
 
-        {card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>}
+        {!showAuthor && (card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>)}
       </div>
     );
 
@@ -235,10 +253,14 @@ export default class Card extends PureComponent {
     }
 
     return (
-      <a href={card.get('url')} className={classNames('status-card', { expanded: largeImage })} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
-        {embed}
-        {description}
-      </a>
+      <>
+        <a href={card.get('url')} className={classNames('status-card', { expanded: largeImage, bottomless: showAuthor })} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
+          {embed}
+          {description}
+        </a>
+
+        {showAuthor && <MoreFromAuthor author={card.get('author_account')} />}
+      </>
     );
   }
 
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 56e4612c1..63298d59e 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -414,6 +414,7 @@
   "limited_account_hint.action": "Show profile anyway",
   "limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
   "link_preview.author": "By {name}",
+  "link_preview.more_from_author": "More from {name}",
   "lists.account.add": "Add to list",
   "lists.account.remove": "Remove from list",
   "lists.delete": "Delete list",
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 859c6e326..4f36d85aa 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3896,6 +3896,10 @@ $ui-header-logo-wordmark-width: 99px;
   border: 1px solid var(--background-border-color);
   border-radius: 8px;
 
+  &.bottomless {
+    border-radius: 8px 8px 0 0;
+  }
+
   &__actions {
     bottom: 0;
     inset-inline-start: 0;
@@ -10223,3 +10227,42 @@ noscript {
     }
   }
 }
+
+.more-from-author {
+  font-size: 14px;
+  color: $darker-text-color;
+  background: var(--surface-background-color);
+  border: 1px solid var(--background-border-color);
+  border-top: 0;
+  border-radius: 0 0 8px 8px;
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  .logo {
+    height: 16px;
+    color: $darker-text-color;
+  }
+
+  & > span {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  a {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    font-weight: 500;
+    color: $primary-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: $highlight-text-color;
+    }
+  }
+}
diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb
index 07776c369..2e49d3fb4 100644
--- a/app/lib/link_details_extractor.rb
+++ b/app/lib/link_details_extractor.rb
@@ -195,6 +195,10 @@ class LinkDetailsExtractor
     structured_data&.author_url
   end
 
+  def author_account
+    opengraph_tag('fediverse:creator')
+  end
+
   def embed_url
     valid_url_or_nil(opengraph_tag('twitter:player:stream'))
   end
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 9fe02bd16..11fdd9d88 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -32,6 +32,7 @@
 #  link_type                    :integer
 #  published_at                 :datetime
 #  image_description            :string           default(""), not null
+#  author_account_id            :bigint(8)
 #
 
 class PreviewCard < ApplicationRecord
@@ -54,6 +55,7 @@ class PreviewCard < ApplicationRecord
   has_many :statuses, through: :preview_cards_statuses
 
   has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
+  belongs_to :author_account, class_name: 'Account', optional: true
 
   has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 90 +profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, validate_media_type: false
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 9d09fa5fe..baa657800 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -157,9 +157,9 @@ class Status < ApplicationRecord
                    :status_stat,
                    :tags,
                    :preloadable_poll,
-                   preview_cards_status: [:preview_card],
+                   preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
                    account: [:account_stat, user: :role],
-                   active_mentions: { account: :account_stat },
+                   active_mentions: :account,
                    reblog: [
                      :application,
                      :tags,
@@ -167,11 +167,11 @@ class Status < ApplicationRecord
                      :conversation,
                      :status_stat,
                      :preloadable_poll,
-                     preview_cards_status: [:preview_card],
+                     preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
                      account: [:account_stat, user: :role],
-                     active_mentions: { account: :account_stat },
+                     active_mentions: :account,
                    ],
-                   thread: { account: :account_stat }
+                   thread: :account
 
   delegate :domain, to: :account, prefix: true
 
diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb
index 039262cd5..7d4c99c2d 100644
--- a/app/serializers/rest/preview_card_serializer.rb
+++ b/app/serializers/rest/preview_card_serializer.rb
@@ -8,6 +8,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
              :provider_url, :html, :width, :height,
              :image, :image_description, :embed_url, :blurhash, :published_at
 
+  has_one :author_account, serializer: REST::AccountSerializer, if: -> { object.author_account.present? }
+
   def url
     object.original_url.presence || object.url
   end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 36e866b6c..900cb9863 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -147,9 +147,12 @@ class FetchLinkCardService < BaseService
     return if html.nil?
 
     link_details_extractor = LinkDetailsExtractor.new(@url, @html, @html_charset)
+    provider = PreviewCardProvider.matching_domain(Addressable::URI.parse(link_details_extractor.canonical_url).normalized_host)
+    linked_account = ResolveAccountService.new.call(link_details_extractor.author_account, suppress_errors: true) if link_details_extractor.author_account.present? && provider&.trendable?
 
     @card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url
     @card.assign_attributes(link_details_extractor.to_preview_card_attributes)
+    @card.author_account = linked_account
     @card.save_with_optional_image! unless @card.title.blank? && @card.html.blank?
   end
 end
diff --git a/db/migrate/20240522041528_add_author_account_id_to_preview_cards.rb b/db/migrate/20240522041528_add_author_account_id_to_preview_cards.rb
new file mode 100644
index 000000000..a6e7a883d
--- /dev/null
+++ b/db/migrate/20240522041528_add_author_account_id_to_preview_cards.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddAuthorAccountIdToPreviewCards < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured { add_reference :preview_cards, :author_account, null: true, foreign_key: { to_table: 'accounts', on_delete: :nullify }, index: false }
+    add_index :preview_cards, :author_account_id, algorithm: :concurrently, where: 'author_account_id IS NOT NULL'
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ad5860492..3a47522d2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[7.1].define(version: 2024_05_10_192043) do
+ActiveRecord::Schema[7.1].define(version: 2024_05_22_041528) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
 
@@ -877,6 +877,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_10_192043) do
     t.integer "link_type"
     t.datetime "published_at"
     t.string "image_description", default: "", null: false
+    t.bigint "author_account_id"
+    t.index ["author_account_id"], name: "index_preview_cards_on_author_account_id", where: "(author_account_id IS NOT NULL)"
     t.index ["url"], name: "index_preview_cards_on_url", unique: true
   end
 
@@ -1352,6 +1354,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_10_192043) do
   add_foreign_key "polls", "accounts", on_delete: :cascade
   add_foreign_key "polls", "statuses", on_delete: :cascade
   add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
+  add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
   add_foreign_key "report_notes", "accounts", on_delete: :cascade
   add_foreign_key "report_notes", "reports", on_delete: :cascade
   add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify