diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8f1839c42..dab52e4c6 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -26,10 +26,10 @@ cache: &global_cache_policy
     - _build
 
 stages:
-  - check-changelog
   - build
   - lint
   - test
+  - check-changelog
   - benchmark
   - deploy
   - release
@@ -113,7 +113,7 @@ benchmark:
   variables:
     MIX_ENV: benchmark
   services:
-  - name: postgres:9.6-alpine
+  - name: postgres:11.22-alpine
     alias: postgres
     command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
   script:
@@ -169,25 +169,6 @@ unit-testing-1.12-erratic:
     - mix ecto.migrate
     - mix test --only=erratic
 
-unit-testing-1.12-rum:
-  extends:
-  - .build_changes_policy
-  - .using-ci-base
-  stage: test
-  cache: *testing_cache_policy
-  services:
-  - name: git.pleroma.social:5050/pleroma/pleroma/postgres-with-rum-13
-    alias: postgres
-    command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
-  variables:
-    <<: *global_variables
-    RUM_ENABLED: "true"
-  script:
-    - mix ecto.create
-    - mix ecto.migrate
-    - "mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/"
-    - mix test --preload-modules
-
 formatting-1.13:
   extends: .build_changes_policy
   image: &formatting_elixir elixir:1.13-alpine
diff --git a/changelog.d/backups-follows.add b/changelog.d/backups-follows.add
new file mode 100644
index 000000000..a55c436f6
--- /dev/null
+++ b/changelog.d/backups-follows.add
@@ -0,0 +1 @@
+Include following/followers in backups
\ No newline at end of file
diff --git a/changelog.d/bookmark-folders.add b/changelog.d/bookmark-folders.add
new file mode 100644
index 000000000..d9b03cecc
--- /dev/null
+++ b/changelog.d/bookmark-folders.add
@@ -0,0 +1 @@
+Allow to group bookmarks in folders
\ No newline at end of file
diff --git a/changelog.d/bookmark-folders.skip b/changelog.d/bookmark-folders.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/card-image-description.add b/changelog.d/card-image-description.add
new file mode 100644
index 000000000..bf423ebb8
--- /dev/null
+++ b/changelog.d/card-image-description.add
@@ -0,0 +1 @@
+Include image description in status media cards
\ No newline at end of file
diff --git a/changelog.d/fep-2c59.add b/changelog.d/fep-2c59.add
new file mode 100644
index 000000000..03e33cbd8
--- /dev/null
+++ b/changelog.d/fep-2c59.add
@@ -0,0 +1 @@
+Implement FEP-2c59, add "webfinger" to user actor
\ No newline at end of file
diff --git a/changelog.d/ffmpeg-limiter.add b/changelog.d/ffmpeg-limiter.add
new file mode 100644
index 000000000..e4a5ef196
--- /dev/null
+++ b/changelog.d/ffmpeg-limiter.add
@@ -0,0 +1 @@
+Framegrabs with ffmpeg will execute with a 5 second timeout and cache the URLs of failures with a TTL of 15 minutes to prevent excessive retries.
diff --git a/changelog.d/fix-bookmark-folder-tests.skip b/changelog.d/fix-bookmark-folder-tests.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/force-mention-mrf.add b/changelog.d/force-mention-mrf.add
new file mode 100644
index 000000000..46ac14244
--- /dev/null
+++ b/changelog.d/force-mention-mrf.add
@@ -0,0 +1 @@
+Add ForceMention MRF
\ No newline at end of file
diff --git a/changelog.d/framegrabs.fix b/changelog.d/framegrabs.fix
new file mode 100644
index 000000000..dc0466f1b
--- /dev/null
+++ b/changelog.d/framegrabs.fix
@@ -0,0 +1 @@
+Video framegrabs were not working correctly after the change to use Exile to execute ffmpeg
diff --git a/changelog.d/instance-contact-account.add b/changelog.d/instance-contact-account.add
new file mode 100644
index 000000000..e119446d2
--- /dev/null
+++ b/changelog.d/instance-contact-account.add
@@ -0,0 +1 @@
+Add contact account to InstanceView
\ No newline at end of file
diff --git a/changelog.d/issue-3241.fix b/changelog.d/issue-3241.fix
new file mode 100644
index 000000000..d46db9805
--- /dev/null
+++ b/changelog.d/issue-3241.fix
@@ -0,0 +1 @@
+Handle cases when users.inbox is nil.
diff --git a/changelog.d/link-verification.add b/changelog.d/link-verification.add
new file mode 100644
index 000000000..d8b11ebbc
--- /dev/null
+++ b/changelog.d/link-verification.add
@@ -0,0 +1 @@
+Verify profile link ownership with rel="me"
\ No newline at end of file
diff --git a/changelog.d/notifications.fix b/changelog.d/notifications.fix
new file mode 100644
index 000000000..a2d2eaea9
--- /dev/null
+++ b/changelog.d/notifications.fix
@@ -0,0 +1 @@
+Notifications: improve performance by filtering on users table instead of activities table
\ No newline at end of file
diff --git a/changelog.d/postgres-jit.change b/changelog.d/postgres-jit.change
new file mode 100644
index 000000000..38225b06b
--- /dev/null
+++ b/changelog.d/postgres-jit.change
@@ -0,0 +1 @@
+Disable jit by default for PostgreSQL
diff --git a/changelog.d/public-polls.add b/changelog.d/public-polls.add
new file mode 100644
index 000000000..0dae0c38e
--- /dev/null
+++ b/changelog.d/public-polls.add
@@ -0,0 +1 @@
+Expose nonAnonymous field from Smithereen polls
\ No newline at end of file
diff --git a/changelog.d/receiverworker-error-handling.fix b/changelog.d/receiverworker-error-handling.fix
new file mode 100644
index 000000000..f017a2bba
--- /dev/null
+++ b/changelog.d/receiverworker-error-handling.fix
@@ -0,0 +1 @@
+ReceiverWorker: Make sure non-{:ok, _} is returned as {:error, …}
\ No newline at end of file
diff --git a/changelog.d/test-improvements.skip b/changelog.d/test-improvements.skip
new file mode 100644
index 000000000..e69de29bb
diff --git a/changelog.d/transient-validators-defaults.change b/changelog.d/transient-validators-defaults.change
new file mode 100644
index 000000000..225cf4d0c
--- /dev/null
+++ b/changelog.d/transient-validators-defaults.change
@@ -0,0 +1 @@
+Set default values on validators for transient objects (attachment, poll options)
diff --git a/config/config.exs b/config/config.exs
index 435387a64..32c8509be 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -415,6 +415,10 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil
 
 config :pleroma, :mrf_inline_quote, template: "<bdi>RT:</bdi> {url}"
 
+config :pleroma, :mrf_force_mention,
+  mention_parent: true,
+  mention_quoted: true
+
 config :pleroma, :rich_media,
   enabled: true,
   ignore_hosts: [],
@@ -795,7 +799,7 @@ config :pleroma, :modules, runtime_dir: "instance/modules"
 config :pleroma, configurable_from_database: false
 
 config :pleroma, Pleroma.Repo,
-  parameters: [gin_fuzzy_search_limit: "500"],
+  parameters: [gin_fuzzy_search_limit: "500", jit: "off"],
   prepare: :unnamed
 
 config :pleroma, :connections_pool,
diff --git a/config/description.exs b/config/description.exs
index b1730bab3..7d012b4b5 100644
--- a/config/description.exs
+++ b/config/description.exs
@@ -574,6 +574,12 @@ config :pleroma, :config_description, [
           "https://status.pleroma.example.org"
         ]
       },
+      %{
+        key: :contact_username,
+        type: :string,
+        description: "Instance owner username",
+        suggestions: ["admin"]
+      },
       %{
         key: :limit,
         type: :integer,
diff --git a/config/test.exs b/config/test.exs
index 9d752bdf8..80b01932c 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -49,7 +49,7 @@ config :pleroma, Pleroma.Repo,
   hostname: System.get_env("DB_HOST") || "localhost",
   port: System.get_env("DB_PORT") || "5432",
   pool: Ecto.Adapters.SQL.Sandbox,
-  pool_size: 50
+  pool_size: System.schedulers_online() * 2
 
 config :pleroma, :dangerzone, override_repo_pool_size: true
 
diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 7bba7b26e..89a461b47 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -161,7 +161,8 @@ To add configuration to your config file, you can copy it from the base config.
     * `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
     * `Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent`: Forces every mentioned user to be reflected in the post content.
     * `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Forces quote post URLs to be reflected in the message content inline.
-    * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)
+    * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions).
+    * `Pleroma.Web.ActivityPub.MRF.ForceMention`: Forces posts to include a mention of the author of parent post or the author of quoted post.
 * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
 * `transparency_exclusions`: Exclude specific instance names from MRF transparency.  The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.
 
@@ -272,6 +273,10 @@ Notes:
 #### :mrf_inline_quote
 * `template`: The template to append to the post. `{url}` will be replaced with the actual link to the quoted post. Default: `<bdi>RT:</bdi> {url}`
 
+#### :mrf_force_mention
+* `mention_parent`: Whether to append mention of parent post author
+* `mention_quoted`: Whether to append mention of parent quoted author
+
 ### :activitypub
 * `unfollow_blocked`: Whether blocks result in people getting unfollowed
 * `outgoing_blocks`: Whether to federate blocks to other instances
diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md
index 2937b2301..e3b6a3c77 100644
--- a/docs/development/API/differences_in_mastoapi_responses.md
+++ b/docs/development/API/differences_in_mastoapi_responses.md
@@ -40,6 +40,8 @@ Has these additional fields under the `pleroma` object:
 - `parent_visible`: If the parent of this post is visible to the user or not.
 - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
 - `quotes_count`: the count of status quotes.
+- `non_anonymous`: true if the source post specifies the poll results are not anonymous. Currently only implemented by Smithereen.
+- `bookmark_folder`: the ID of the folder bookmark is stored within (if any).
 
 The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
 
@@ -65,6 +67,12 @@ Some apps operate under the assumption that no more than 4 attachments can be re
 
 Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it.
 
+## Bookmarks
+
+The `GET /api/v1/bookmarks` endpoint accepts optional parameter `folder_id` for bookmark folder ID.
+
+The `POST /api/v1/statuses/:id/bookmark` endpoint accepts optional parameter `folder_id` for bookmark folder ID.
+
 ## Accounts
 
 The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc.
diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md
index 060af5c14..267dfc1ec 100644
--- a/docs/development/API/pleroma_api.md
+++ b/docs/development/API/pleroma_api.md
@@ -283,6 +283,52 @@ See [Admin-API](admin_api.md)
     * `id`: the id of the status
 * Response: JSON, returns a list of Mastodon Status entities
 
+## `GET /api/v1/pleroma/bookmark_folders`
+### Gets user bookmark folders
+* Authentication: required
+
+* Response: JSON. Returns a list of bookmark folders.
+* Example response:
+```json
+[
+    {
+        "id": "9umDrYheeY451cQnEe",
+        "name": "Read later",
+        "emoji": "🕓",
+        "source": {
+          "emoji": "🕓"
+        }
+    }
+]
+```
+
+## `POST /api/v1/pleroma/bookmark_folders`
+### Creates a bookmark folder
+* Authentication: required
+
+* Params:
+    * `name`: folder name
+    * `emoji`: folder emoji (optional)
+* Response: JSON. Returns a single bookmark folder.
+
+## `PATCH /api/v1/pleroma/bookmark_folders/:id`
+### Updates a bookmark folder
+* Authentication: required
+
+* Params:
+    * `id`: folder id
+    * `name`: folder name (optional)
+    * `emoji`: folder emoji (optional)
+* Response: JSON. Returns a single bookmark folder.
+
+## `DELETE /api/v1/pleroma/bookmark_folders/:id`
+### Deletes a bookmark folder
+* Authentication: required
+
+* Params:
+    * `id`: folder id
+* Response: JSON. Returns a single bookmark folder.
+
 ## `/api/v1/pleroma/mascot`
 ### Gets user mascot image
 * Method `GET`
diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md
index 1424ad7f4..b6b5c9c07 100644
--- a/docs/installation/debian_based_jp.md
+++ b/docs/installation/debian_based_jp.md
@@ -12,8 +12,8 @@ Note: This article is potentially outdated because at this time we may not have
 
 ### 必要なソフトウェア
 
-- PostgreSQL 9.6以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください)
-- `postgresql-contrib` 9.6以上 (同上)
+- PostgreSQL 11.0以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください)
+- `postgresql-contrib` 11.0以上 (同上)
 - Elixir 1.8 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください)
 - `erlang-dev`
 - `erlang-nox`
diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include
index aebf21e7c..6572716ed 100644
--- a/docs/installation/generic_dependencies.include
+++ b/docs/installation/generic_dependencies.include
@@ -1,6 +1,6 @@
 ## Required dependencies
 
-* PostgreSQL >=9.6
+* PostgreSQL >=11.0
 * Elixir >=1.11.0 <1.15
 * Erlang OTP >=22.2.0 (supported: <27)
 * git
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex
index 921384be9..5e6b227dc 100644
--- a/lib/pleroma/application.ex
+++ b/lib/pleroma/application.ex
@@ -119,28 +119,7 @@ defmodule Pleroma.Application do
     max_restarts = Application.get_env(:pleroma, __MODULE__)[:max_restarts]
 
     opts = [strategy: :one_for_one, name: Pleroma.Supervisor, max_restarts: max_restarts]
-    result = Supervisor.start_link(children, opts)
-
-    set_postgres_server_version()
-
-    result
-  end
-
-  defp set_postgres_server_version do
-    version =
-      with %{rows: [[version]]} <- Ecto.Adapters.SQL.query!(Pleroma.Repo, "show server_version"),
-           {num, _} <- Float.parse(version) do
-        num
-      else
-        e ->
-          Logger.warning(
-            "Could not get the postgres version: #{inspect(e)}.\nSetting the default value of 9.6"
-          )
-
-          9.6
-      end
-
-    :persistent_term.put({Pleroma.Repo, :postgres_version}, version)
+    Supervisor.start_link(children, opts)
   end
 
   def load_custom_modules do
@@ -177,6 +156,7 @@ defmodule Pleroma.Application do
       build_cachex("web_resp", limit: 2500),
       build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
       build_cachex("failed_proxy_url", limit: 2500),
+      build_cachex("failed_media_helper_url", default_ttl: :timer.minutes(15), limit: 2_500),
       build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
       build_cachex("chat_message_id_idempotency_key",
         expiration: chat_message_id_idempotency_key_expiration(),
diff --git a/lib/pleroma/bookmark.ex b/lib/pleroma/bookmark.ex
index b83d72446..1a2a63b82 100644
--- a/lib/pleroma/bookmark.ex
+++ b/lib/pleroma/bookmark.ex
@@ -10,6 +10,7 @@ defmodule Pleroma.Bookmark do
 
   alias Pleroma.Activity
   alias Pleroma.Bookmark
+  alias Pleroma.BookmarkFolder
   alias Pleroma.Repo
   alias Pleroma.User
 
@@ -18,33 +19,46 @@ defmodule Pleroma.Bookmark do
   schema "bookmarks" do
     belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
     belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
+    belongs_to(:folder, BookmarkFolder, type: FlakeId.Ecto.CompatType)
 
     timestamps()
   end
 
   @spec create(Ecto.UUID.t(), Ecto.UUID.t()) ::
           {:ok, Bookmark.t()} | {:error, Ecto.Changeset.t()}
-  def create(user_id, activity_id) do
+  def create(user_id, activity_id, folder_id \\ nil) do
     attrs = %{
       user_id: user_id,
-      activity_id: activity_id
+      activity_id: activity_id,
+      folder_id: folder_id
     }
 
     %Bookmark{}
-    |> cast(attrs, [:user_id, :activity_id])
+    |> cast(attrs, [:user_id, :activity_id, :folder_id])
     |> validate_required([:user_id, :activity_id])
     |> unique_constraint(:activity_id, name: :bookmarks_user_id_activity_id_index)
-    |> Repo.insert()
+    |> Repo.insert(
+      on_conflict: [set: [folder_id: folder_id]],
+      conflict_target: [:user_id, :activity_id]
+    )
   end
 
   @spec for_user_query(Ecto.UUID.t()) :: Ecto.Query.t()
-  def for_user_query(user_id) do
+  def for_user_query(user_id, folder_id \\ nil) do
     Bookmark
     |> where(user_id: ^user_id)
+    |> maybe_filter_by_folder(folder_id)
     |> join(:inner, [b], activity in assoc(b, :activity))
     |> preload([b, a], activity: a)
   end
 
+  defp maybe_filter_by_folder(query, nil), do: query
+
+  defp maybe_filter_by_folder(query, folder_id) do
+    query
+    |> where(folder_id: ^folder_id)
+  end
+
   def get(user_id, activity_id) do
     Bookmark
     |> where(user_id: ^user_id)
@@ -62,4 +76,11 @@ defmodule Pleroma.Bookmark do
     |> Repo.one()
     |> Repo.delete()
   end
+
+  def set_folder(bookmark, folder_id) do
+    bookmark
+    |> cast(%{folder_id: folder_id}, [:folder_id])
+    |> validate_required([:folder_id])
+    |> Repo.update()
+  end
 end
diff --git a/lib/pleroma/bookmark_folder.ex b/lib/pleroma/bookmark_folder.ex
new file mode 100644
index 000000000..14d37e197
--- /dev/null
+++ b/lib/pleroma/bookmark_folder.ex
@@ -0,0 +1,115 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.BookmarkFolder do
+  use Ecto.Schema
+
+  import Ecto.Changeset
+  import Ecto.Query
+
+  alias Pleroma.BookmarkFolder
+  alias Pleroma.Emoji
+  alias Pleroma.Repo
+  alias Pleroma.User
+
+  @type t :: %__MODULE__{}
+  @primary_key {:id, FlakeId.Ecto.CompatType, autogenerate: true}
+
+  schema "bookmark_folders" do
+    field(:name, :string)
+    field(:emoji, :string)
+
+    belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
+
+    timestamps()
+  end
+
+  def get_by_id(id), do: Repo.get_by(BookmarkFolder, id: id)
+
+  def create(user_id, name, emoji \\ nil) do
+    %BookmarkFolder{}
+    |> cast(
+      %{
+        user_id: user_id,
+        name: name,
+        emoji: emoji
+      },
+      [:user_id, :name, :emoji]
+    )
+    |> validate_required([:user_id, :name])
+    |> fix_emoji()
+    |> validate_emoji()
+    |> unique_constraint([:user_id, :name])
+    |> Repo.insert()
+  end
+
+  def update(folder_id, name, emoji \\ nil) do
+    get_by_id(folder_id)
+    |> cast(
+      %{
+        name: name,
+        emoji: emoji
+      },
+      [:name, :emoji]
+    )
+    |> fix_emoji()
+    |> validate_emoji()
+    |> unique_constraint([:user_id, :name])
+    |> Repo.update()
+  end
+
+  defp fix_emoji(changeset) do
+    with {:emoji_field, emoji} when is_binary(emoji) <-
+           {:emoji_field, get_field(changeset, :emoji)},
+         {:fixed_emoji, emoji} <-
+           {:fixed_emoji,
+            emoji
+            |> Pleroma.Emoji.fully_qualify_emoji()
+            |> Pleroma.Emoji.maybe_quote()} do
+      put_change(changeset, :emoji, emoji)
+    else
+      {:emoji_field, _} -> changeset
+    end
+  end
+
+  defp validate_emoji(changeset) do
+    validate_change(changeset, :emoji, fn
+      :emoji, nil ->
+        []
+
+      :emoji, emoji ->
+        if Emoji.unicode?(emoji) or valid_local_custom_emoji?(emoji) do
+          []
+        else
+          [emoji: "Invalid emoji"]
+        end
+    end)
+  end
+
+  defp valid_local_custom_emoji?(emoji) do
+    with %{file: _path} <- Emoji.get(emoji) do
+      true
+    else
+      _ -> false
+    end
+  end
+
+  def delete(folder_id) do
+    BookmarkFolder
+    |> Repo.get_by(id: folder_id)
+    |> Repo.delete()
+  end
+
+  def for_user(user_id) do
+    BookmarkFolder
+    |> where(user_id: ^user_id)
+    |> Repo.all()
+  end
+
+  def belongs_to_user?(folder_id, user_id) do
+    BookmarkFolder
+    |> where(id: ^folder_id, user_id: ^user_id)
+    |> Repo.exists?()
+  end
+end
diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex
index 15664c876..f38c2fce9 100644
--- a/lib/pleroma/following_relationship.ex
+++ b/lib/pleroma/following_relationship.ex
@@ -241,13 +241,13 @@ defmodule Pleroma.FollowingRelationship do
   end
 
   @doc """
-  For a query with joined activity,
-  keeps rows where activity's actor is followed by user -or- is NOT domain-blocked by user.
+  For a query with joined activity's actor,
+  keeps rows where actor is followed by user -or- is NOT domain-blocked by user.
   """
   def keep_following_or_not_domain_blocked(query, user) do
     where(
       query,
-      [_, activity],
+      [_, user_actor: user_actor],
       fragment(
         # "(actor's domain NOT in domain_blocks) OR (actor IS in followed AP IDs)"
         """
@@ -255,9 +255,9 @@ defmodule Pleroma.FollowingRelationship do
           ? = ANY(SELECT ap_id FROM users AS u INNER JOIN following_relationships AS fr
             ON u.id = fr.following_id WHERE fr.follower_id = ? AND fr.state = ?)
         """,
-        activity.actor,
+        user_actor.ap_id,
         ^user.domain_blocks,
-        activity.actor,
+        user_actor.ap_id,
         ^User.binary_id(user.id),
         ^accept_state_code()
       )
diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex
index 1a414b37f..e44114d9d 100644
--- a/lib/pleroma/helpers/media_helper.ex
+++ b/lib/pleroma/helpers/media_helper.ex
@@ -12,6 +12,8 @@ defmodule Pleroma.Helpers.MediaHelper do
 
   require Logger
 
+  @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
   def missing_dependencies do
     Enum.reduce([ffmpeg: "ffmpeg"], [], fn {sym, executable}, acc ->
       if Pleroma.Utils.command_available?(executable) do
@@ -40,28 +42,43 @@ defmodule Pleroma.Helpers.MediaHelper do
   end
 
   # Note: video thumbnail is intentionally not resized (always has original dimensions)
+  @spec video_framegrab(String.t()) :: {:ok, binary()} | {:error, any()}
   def video_framegrab(url) do
     with executable when is_binary(executable) <- System.find_executable("ffmpeg"),
+         false <- @cachex.exists?(:failed_media_helper_cache, url),
          {:ok, env} <- HTTP.get(url, [], pool: :media),
          {:ok, pid} <- StringIO.open(env.body) do
       body_stream = IO.binstream(pid, 1)
 
-      Exile.stream!(
-        [
-          executable,
-          "-i",
-          "pipe:0",
-          "-vframes",
-          "1",
-          "-f",
-          "mjpeg",
-          "pipe:1"
-        ],
-        input: body_stream,
-        ignore_epipe: true,
-        stderr: :disable
-      )
-      |> Enum.into(<<>>)
+      task =
+        Task.async(fn ->
+          Exile.stream!(
+            [
+              executable,
+              "-i",
+              "pipe:0",
+              "-vframes",
+              "1",
+              "-f",
+              "mjpeg",
+              "pipe:1"
+            ],
+            input: body_stream,
+            ignore_epipe: true,
+            stderr: :disable
+          )
+          |> Enum.into(<<>>)
+        end)
+
+      case Task.yield(task, 5_000) do
+        nil ->
+          Task.shutdown(task)
+          @cachex.put(:failed_media_helper_cache, url, nil)
+          {:error, {:ffmpeg, :timeout}}
+
+        result ->
+          {:ok, result}
+      end
     else
       nil -> {:error, {:ffmpeg, :command_not_found}}
       {:error, _} = error -> error
diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex
index 368e609d2..710b19866 100644
--- a/lib/pleroma/notification.ex
+++ b/lib/pleroma/notification.ex
@@ -137,7 +137,7 @@ defmodule Pleroma.Notification do
     blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
 
     query
-    |> where([n, a], a.actor not in ^blocked_ap_ids)
+    |> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocked_ap_ids)
     |> FollowingRelationship.keep_following_or_not_domain_blocked(user)
   end
 
@@ -148,7 +148,7 @@ defmodule Pleroma.Notification do
       blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block])
 
       query
-      |> where([n, a], a.actor not in ^blocker_ap_ids)
+      |> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocker_ap_ids)
     end
   end
 
@@ -161,7 +161,7 @@ defmodule Pleroma.Notification do
       opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user)
 
     query
-    |> where([n, a], a.actor not in ^notification_muted_ap_ids)
+    |> where([..., user_actor: user_actor], user_actor.ap_id not in ^notification_muted_ap_ids)
     |> join(:left, [n, a], tm in ThreadMute,
       on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data),
       as: :thread_mute
diff --git a/lib/pleroma/search/database_search.ex b/lib/pleroma/search/database_search.ex
index c6311e0c7..31bfc7e33 100644
--- a/lib/pleroma/search/database_search.ex
+++ b/lib/pleroma/search/database_search.ex
@@ -23,19 +23,12 @@ defmodule Pleroma.Search.DatabaseSearch do
     offset = Keyword.get(options, :offset, 0)
     author = Keyword.get(options, :author)
 
-    search_function =
-      if :persistent_term.get({Pleroma.Repo, :postgres_version}) >= 11 do
-        :websearch
-      else
-        :plain
-      end
-
     try do
       Activity
       |> Activity.with_preloaded_object()
       |> Activity.restrict_deactivated_users()
       |> restrict_public(user)
-      |> query_with(index_type, search_query, search_function)
+      |> query_with(index_type, search_query, :websearch)
       |> maybe_restrict_local(user)
       |> maybe_restrict_author(author)
       |> maybe_restrict_blocked(user)
diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex
index 0773434c5..778e20526 100644
--- a/lib/pleroma/user.ex
+++ b/lib/pleroma/user.ex
@@ -8,6 +8,7 @@ defmodule Pleroma.User do
   import Ecto.Changeset
   import Ecto.Query
   import Ecto, only: [assoc: 2]
+  import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1]
 
   alias Ecto.Multi
   alias Pleroma.Activity
@@ -596,9 +597,23 @@ defmodule Pleroma.User do
 
   defp put_fields(changeset) do
     if raw_fields = get_change(changeset, :raw_fields) do
+      old_fields = changeset.data.raw_fields
+
       raw_fields =
         raw_fields
         |> Enum.filter(fn %{"name" => n} -> n != "" end)
+        |> Enum.map(fn field ->
+          previous =
+            old_fields
+            |> Enum.find(fn %{"value" => value} -> field["value"] == value end)
+
+          if previous && Map.has_key?(previous, "verified_at") do
+            field
+            |> Map.put("verified_at", previous["verified_at"])
+          else
+            field
+          end
+        end)
 
       fields =
         raw_fields
@@ -1200,6 +1215,10 @@ defmodule Pleroma.User do
 
   def update_and_set_cache(changeset) do
     with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
+      if get_change(changeset, :raw_fields) do
+        BackgroundWorker.enqueue("verify_fields_links", %{"user_id" => user.id})
+      end
+
       set_cache(user)
     end
   end
@@ -1975,8 +1994,45 @@ defmodule Pleroma.User do
     maybe_delete_from_db(user)
   end
 
+  def perform(:verify_fields_links, user) do
+    profile_urls = [user.ap_id]
+
+    fields =
+      user.raw_fields
+      |> Enum.map(&verify_field_link(&1, profile_urls))
+
+    changeset =
+      user
+      |> update_changeset(%{raw_fields: fields})
+
+    with {:ok, user} <- Repo.update(changeset, stale_error_field: :id) do
+      set_cache(user)
+    end
+  end
+
   def perform(:set_activation_async, user, status), do: set_activation(user, status)
 
+  defp verify_field_link(field, profile_urls) do
+    verified_at =
+      with %{"value" => value} <- field,
+           {:verified_at, nil} <- {:verified_at, Map.get(field, "verified_at")},
+           %{scheme: scheme, userinfo: nil, host: host}
+           when not_empty_string(host) and scheme in ["http", "https"] <-
+             URI.parse(value),
+           {:not_idn, true} <- {:not_idn, to_string(:idna.encode(host)) == host},
+           "me" <- Pleroma.Web.RelMe.maybe_put_rel_me(value, profile_urls) do
+        CommonUtils.to_masto_date(NaiveDateTime.utc_now())
+      else
+        {:verified_at, value} when not_empty_string(value) ->
+          value
+
+        _ ->
+          nil
+      end
+
+    Map.put(field, "verified_at", verified_at)
+  end
+
   @spec external_users_query() :: Ecto.Query.t()
   def external_users_query do
     User.Query.build(%{
@@ -2664,10 +2720,11 @@ defmodule Pleroma.User do
   # - display name
   def sanitize_html(%User{} = user, filter) do
     fields =
-      Enum.map(user.fields, fn %{"name" => name, "value" => value} ->
+      Enum.map(user.fields, fn %{"name" => name, "value" => value} = fields ->
         %{
           "name" => name,
-          "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly)
+          "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly),
+          "verified_at" => Map.get(fields, "verified_at")
         }
       end)
 
diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex
index b7f00bbf7..65e0baccd 100644
--- a/lib/pleroma/user/backup.ex
+++ b/lib/pleroma/user/backup.ex
@@ -196,7 +196,14 @@ defmodule Pleroma.User.Backup do
     end
   end
 
-  @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json']
+  @files [
+    'actor.json',
+    'outbox.json',
+    'likes.json',
+    'bookmarks.json',
+    'followers.json',
+    'following.json'
+  ]
   @spec export(Pleroma.User.Backup.t(), pid()) :: {:ok, String.t()} | :error
   def export(%__MODULE__{} = backup, caller_pid) do
     backup = Repo.preload(backup, :user)
@@ -207,6 +214,8 @@ defmodule Pleroma.User.Backup do
          :ok <- statuses(dir, backup.user, caller_pid),
          :ok <- likes(dir, backup.user, caller_pid),
          :ok <- bookmarks(dir, backup.user, caller_pid),
+         :ok <- followers(dir, backup.user, caller_pid),
+         :ok <- following(dir, backup.user, caller_pid),
          {:ok, zip_path} <- :zip.create(backup.file_name, @files, cwd: dir),
          {:ok, _} <- File.rm_rf(dir) do
       {:ok, zip_path}
@@ -357,6 +366,16 @@ defmodule Pleroma.User.Backup do
       caller_pid
     )
   end
+
+  defp followers(dir, user, caller_pid) do
+    User.get_followers_query(user)
+    |> write(dir, "followers", fn a -> {:ok, a.ap_id} end, caller_pid)
+  end
+
+  defp following(dir, user, caller_pid) do
+    User.get_friends_query(user)
+    |> write(dir, "following", fn a -> {:ok, a.ap_id} end, caller_pid)
+  end
 end
 
 defmodule Pleroma.User.Backup.ProcessorAPI do
diff --git a/lib/pleroma/web/activity_pub/mrf/force_mention.ex b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
new file mode 100644
index 000000000..3853489fc
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceMention do
+  require Pleroma.Constants
+
+  alias Pleroma.Config
+  alias Pleroma.Object
+  alias Pleroma.User
+
+  @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+  defp get_author(url) do
+    with %Object{data: %{"actor" => actor}} <- Object.normalize(url, fetch: false),
+         %User{ap_id: ap_id, nickname: nickname} <- User.get_cached_by_ap_id(actor) do
+      %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
+    else
+      _ -> nil
+    end
+  end
+
+  defp prepend_author(tags, _, false), do: tags
+
+  defp prepend_author(tags, nil, _), do: tags
+
+  defp prepend_author(tags, url, _) do
+    actor = get_author(url)
+
+    if not is_nil(actor) do
+      [actor | tags]
+    else
+      tags
+    end
+  end
+
+  @impl true
+  def filter(%{"type" => "Create", "object" => %{"tag" => tag} = object} = activity) do
+    tag =
+      tag
+      |> prepend_author(
+        object["inReplyTo"],
+        Config.get([:mrf_force_mention, :mention_parent, true])
+      )
+      |> prepend_author(
+        object["quoteUrl"],
+        Config.get([:mrf_force_mention, :mention_quoted, true])
+      )
+      |> Enum.uniq()
+
+    {:ok, put_in(activity["object"]["tag"], tag)}
+  end
+
+  @impl true
+  def filter(object), do: {:ok, object}
+
+  @impl true
+  def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
index 398020bff..72975f348 100644
--- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex
@@ -12,13 +12,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
   @primary_key false
   embedded_schema do
     field(:id, :string)
-    field(:type, :string)
+    field(:type, :string, default: "Link")
     field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
     field(:name, :string)
     field(:blurhash, :string)
 
     embeds_many :url, UrlObjectValidator, primary_key: false do
-      field(:type, :string)
+      field(:type, :string, default: "Link")
       field(:href, ObjectValidators.Uri)
       field(:mediaType, ObjectValidators.MIME, default: "application/octet-stream")
       field(:width, :integer)
diff --git a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex
index 541945fa4..8d7f7b9fa 100644
--- a/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/question_options_validator.ex
@@ -14,10 +14,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator do
 
     embeds_one :replies, Replies, primary_key: false do
       field(:totalItems, :integer)
-      field(:type, :string)
+      field(:type, :string, default: "Collection")
     end
 
-    field(:type, :string)
+    field(:type, :string, default: "Note")
   end
 
   def changeset(struct, data) do
diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
index 621085e6c..7f9d4d648 100644
--- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
+++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex
@@ -29,6 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do
 
     field(:closed, ObjectValidators.DateTime)
     field(:voters, {:array, ObjectValidators.ObjectID}, default: [])
+    field(:nonAnonymous, :boolean)
     embeds_many(:anyOf, QuestionOptionsValidator)
     embeds_many(:oneOf, QuestionOptionsValidator)
   end
diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex
index 9e7d00519..a42b4844e 100644
--- a/lib/pleroma/web/activity_pub/publisher.ex
+++ b/lib/pleroma/web/activity_pub/publisher.ex
@@ -158,19 +158,18 @@ defmodule Pleroma.Web.ActivityPub.Publisher do
     end
   end
 
-  defp should_federate?(inbox, public) do
-    if public do
-      true
-    else
-      %{host: host} = URI.parse(inbox)
+  def should_federate?(nil, _), do: false
+  def should_federate?(_, true), do: true
 
-      quarantined_instances =
-        Config.get([:instance, :quarantined_instances], [])
-        |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples()
-        |> Pleroma.Web.ActivityPub.MRF.subdomains_regex()
+  def should_federate?(inbox, _) do
+    %{host: host} = URI.parse(inbox)
 
-      !Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host)
-    end
+    quarantined_instances =
+      Config.get([:instance, :quarantined_instances], [])
+      |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples()
+      |> Pleroma.Web.ActivityPub.MRF.subdomains_regex()
+
+    !Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host)
   end
 
   @spec recipients(User.t(), Activity.t()) :: [[User.t()]]
diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex
index 24ee683ae..937e4fd67 100644
--- a/lib/pleroma/web/activity_pub/views/user_view.ex
+++ b/lib/pleroma/web/activity_pub/views/user_view.ex
@@ -67,8 +67,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do
   def render("user.json", %{user: %User{nickname: nil} = user}),
     do: render("service.json", %{user: user})
 
-  def render("user.json", %{user: %User{nickname: "internal." <> _} = user}),
-    do: render("service.json", %{user: user}) |> Map.put("preferredUsername", user.nickname)
+  def render("user.json", %{user: %User{nickname: "internal." <> _} = user}) do
+    render("service.json", %{user: user})
+    |> Map.merge(%{
+      "preferredUsername" => user.nickname,
+      "webfinger" => "acct:#{User.full_nickname(user)}"
+    })
+  end
 
   def render("user.json", %{user: user}) do
     {:ok, _, public_key} = Keys.keys_from_pem(user.keys)
@@ -121,7 +126,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do
       "discoverable" => user.is_discoverable,
       "capabilities" => capabilities,
       "alsoKnownAs" => user.also_known_as,
-      "vcard:bday" => birthday
+      "vcard:bday" => birthday,
+      "webfinger" => "acct:#{User.full_nickname(user)}"
     }
     |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user))
     |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user))
diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex
index 3588608f2..10d221571 100644
--- a/lib/pleroma/web/api_spec.ex
+++ b/lib/pleroma/web/api_spec.ex
@@ -137,7 +137,8 @@ defmodule Pleroma.Web.ApiSpec do
               "Scheduled statuses",
               "Search",
               "Status actions",
-              "Media attachments"
+              "Media attachments",
+              "Bookmark folders"
             ]
           },
           %{
diff --git a/lib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex
new file mode 100644
index 000000000..eaa683125
--- /dev/null
+++ b/lib/pleroma/web/api_spec/operations/pleroma_bookmark_folder_operation.ex
@@ -0,0 +1,125 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.PleromaBookmarkFolderOperation do
+  alias OpenApiSpex.Operation
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.ApiError
+  alias Pleroma.Web.ApiSpec.Schemas.BookmarkFolder
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+
+  import Pleroma.Web.ApiSpec.Helpers
+
+  @spec open_api_operation(any()) :: any()
+  def open_api_operation(action) do
+    operation = String.to_existing_atom("#{action}_operation")
+    apply(__MODULE__, operation, [])
+  end
+
+  def index_operation do
+    %Operation{
+      tags: ["Bookmark folders"],
+      summary: "All bookmark folders",
+      security: [%{"oAuth" => ["read:bookmarks"]}],
+      operationId: "PleromaAPI.BookmarkFolderController.index",
+      responses: %{
+        200 =>
+          Operation.response("Array of Bookmark Folders", "application/json", %Schema{
+            type: :array,
+            items: BookmarkFolder
+          })
+      }
+    }
+  end
+
+  def create_operation do
+    %Operation{
+      tags: ["Bookmark folders"],
+      summary: "Create a bookmark folder",
+      security: [%{"oAuth" => ["write:bookmarks"]}],
+      operationId: "PleromaAPI.BookmarkFolderController.create",
+      requestBody: request_body("Parameters", create_request(), required: true),
+      responses: %{
+        200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder),
+        422 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def update_operation do
+    %Operation{
+      tags: ["Bookmark folders"],
+      summary: "Update a bookmark folder",
+      security: [%{"oAuth" => ["write:bookmarks"]}],
+      operationId: "PleromaAPI.BookmarkFolderController.update",
+      parameters: [id_param()],
+      requestBody: request_body("Parameters", update_request(), required: true),
+      responses: %{
+        200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError),
+        422 => Operation.response("Error", "application/json", ApiError)
+      }
+    }
+  end
+
+  def delete_operation do
+    %Operation{
+      tags: ["Bookmark folders"],
+      summary: "Delete a bookmark folder",
+      security: [%{"oAuth" => ["write:bookmarks"]}],
+      operationId: "PleromaAPI.BookmarkFolderController.delete",
+      parameters: [id_param()],
+      responses: %{
+        200 => Operation.response("Bookmark Folder", "application/json", BookmarkFolder),
+        403 => Operation.response("Forbidden", "application/json", ApiError),
+        404 => Operation.response("Not Found", "application/json", ApiError)
+      }
+    }
+  end
+
+  defp create_request do
+    %Schema{
+      title: "BookmarkFolderCreateRequest",
+      type: :object,
+      properties: %{
+        name: %Schema{
+          type: :string,
+          description: "Folder name"
+        },
+        emoji: %Schema{
+          type: :string,
+          nullable: true,
+          description: "Folder emoji"
+        }
+      }
+    }
+  end
+
+  defp update_request do
+    %Schema{
+      title: "BookmarkFolderUpdateRequest",
+      type: :object,
+      properties: %{
+        name: %Schema{
+          type: :string,
+          nullable: true,
+          description: "Folder name"
+        },
+        emoji: %Schema{
+          type: :string,
+          nullable: true,
+          description: "Folder emoji"
+        }
+      }
+    }
+  end
+
+  def id_param do
+    Operation.parameter(:id, :path, FlakeID.schema(), "Bookmark Folder ID",
+      example: "9umDrYheeY451cQnEe",
+      required: true
+    )
+  end
+end
diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex
index 00529bc47..d87d59ef6 100644
--- a/lib/pleroma/web/api_spec/operations/status_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/status_operation.ex
@@ -256,6 +256,18 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
       description: "Privately bookmark a status",
       operationId: "StatusController.bookmark",
       parameters: [id_param()],
+      requestBody:
+        request_body("Parameters", %Schema{
+          title: "StatusUpdateRequest",
+          type: :object,
+          properties: %{
+            folder_id: %Schema{
+              nullable: true,
+              allOf: [FlakeID],
+              description: "ID of bookmarks folder, if any"
+            }
+          }
+        }),
       responses: %{
         200 => status_response()
       }
@@ -462,7 +474,15 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
       summary: "Bookmarked statuses",
       description: "Statuses the user has bookmarked",
       operationId: "StatusController.bookmarks",
-      parameters: pagination_params(),
+      parameters: [
+        Operation.parameter(
+          :folder_id,
+          :query,
+          FlakeID.schema(),
+          "If provided, only display bookmarks from given folder"
+        )
+        | pagination_params()
+      ],
       security: [%{"oAuth" => ["read:bookmarks"]}],
       responses: %{
         200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())
diff --git a/lib/pleroma/web/api_spec/schemas/bookmark_folder.ex b/lib/pleroma/web/api_spec/schemas/bookmark_folder.ex
new file mode 100644
index 000000000..e8b4f43b7
--- /dev/null
+++ b/lib/pleroma/web/api_spec/schemas/bookmark_folder.ex
@@ -0,0 +1,26 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ApiSpec.Schemas.BookmarkFolder do
+  alias OpenApiSpex.Schema
+  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+
+  require OpenApiSpex
+
+  OpenApiSpex.schema(%{
+    title: "BookmarkFolder",
+    description: "Response schema for a bookmark folder",
+    type: :object,
+    properties: %{
+      id: FlakeID,
+      name: %Schema{type: :string, description: "Folder name"},
+      emoji: %Schema{type: :string, description: "Folder emoji", nullable: true}
+    },
+    example: %{
+      "id" => "9toJCu5YZW7O7gfvH6",
+      "name" => "Read later",
+      "emoji" => nil
+    }
+  })
+end
diff --git a/lib/pleroma/web/api_spec/schemas/poll.ex b/lib/pleroma/web/api_spec/schemas/poll.ex
index 91570582b..20cf5b061 100644
--- a/lib/pleroma/web/api_spec/schemas/poll.ex
+++ b/lib/pleroma/web/api_spec/schemas/poll.ex
@@ -56,6 +56,15 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
           }
         },
         description: "Possible answers for the poll."
+      },
+      pleroma: %Schema{
+        type: :object,
+        properties: %{
+          non_anonymous: %Schema{
+            type: :boolean,
+            description: "Can voters be publicly identified?"
+          }
+        }
       }
     },
     example: %{
@@ -79,7 +88,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
           votes_count: 4
         }
       ],
-      emojis: []
+      emojis: [],
+      pleroma: %{
+        non_anonymous: false
+      }
     }
   })
 end
diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex
index a4052803b..6e537b5da 100644
--- a/lib/pleroma/web/api_spec/schemas/status.ex
+++ b/lib/pleroma/web/api_spec/schemas/status.ex
@@ -58,6 +58,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
             format: :uri,
             description: "Preview thumbnail"
           },
+          image_description: %Schema{
+            type: :string,
+            description: "Alternate text that describes what is in the thumbnail"
+          },
           title: %Schema{type: :string, description: "Title of linked resource"},
           description: %Schema{type: :string, description: "Description of preview"}
         }
diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
index ad1e78c30..9344b346a 100644
--- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex
@@ -12,6 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
 
   alias Pleroma.Activity
   alias Pleroma.Bookmark
+  alias Pleroma.BookmarkFolder
   alias Pleroma.Language.Translation
   alias Pleroma.Object
   alias Pleroma.Repo
@@ -414,13 +415,22 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
 
   @doc "POST /api/v1/statuses/:id/bookmark"
   def bookmark(
-        %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
+        %{
+          assigns: %{user: user},
+          private: %{open_api_spex: %{body_params: body_params, params: %{id: id}}}
+        } = conn,
         _
       ) do
     with %Activity{} = activity <- Activity.get_by_id_with_object(id),
          %User{} = user <- User.get_cached_by_nickname(user.nickname),
          true <- Visibility.visible_for_user?(activity, user),
-         {:ok, _bookmark} <- Bookmark.create(user.id, activity.id) do
+         folder_id <- Map.get(body_params, :folder_id, nil),
+         folder_id <-
+           if(folder_id && BookmarkFolder.belongs_to_user?(folder_id, user.id),
+             do: folder_id,
+             else: nil
+           ),
+         {:ok, _bookmark} <- Bookmark.create(user.id, activity.id, folder_id) do
       try_render(conn, "show.json", activity: activity, for: user, as: :activity)
     end
   end
@@ -611,10 +621,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
   @doc "GET /api/v1/bookmarks"
   def bookmarks(%{assigns: %{user: user}, private: %{open_api_spex: %{params: params}}} = conn, _) do
     user = User.get_cached_by_id(user.id)
+    folder_id = Map.get(params, :folder_id)
 
     bookmarks =
       user.id
-      |> Bookmark.for_user_query()
+      |> Bookmark.for_user_query(folder_id)
       |> Pleroma.Pagination.fetch_paginated(params)
 
     activities =
diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex
index e093b1a2f..229679c39 100644
--- a/lib/pleroma/web/mastodon_api/views/instance_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex
@@ -28,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
         |> to_string,
       registrations: Keyword.get(instance, :registrations_open),
       approval_required: Keyword.get(instance, :account_approval_required),
+      contact_account: contact_account(Keyword.get(instance, :contact_username)),
       configuration: configuration(),
       # Extra (not present in Mastodon):
       max_toot_chars: Keyword.get(instance, :limit),
@@ -68,7 +69,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       },
       contact: %{
         email: Keyword.get(instance, :email),
-        account: nil
+        account: contact_account(Keyword.get(instance, :contact_username))
       },
       # Extra (not present in Mastodon):
       pleroma: pleroma_configuration2(instance)
@@ -139,6 +140,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
       end,
       "pleroma:get:main/ostatus",
       "pleroma:group_actors",
+      "pleroma:bookmark_folders",
       if Pleroma.Language.Translation.configured?() do
         "translation"
       end
@@ -182,6 +184,22 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
     }
   end
 
+  defp contact_account(nil), do: nil
+
+  defp contact_account("@" <> username) do
+    contact_account(username)
+  end
+
+  defp contact_account(username) do
+    user = Pleroma.User.get_cached_by_nickname(username)
+
+    if user do
+      Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user, for: nil})
+    else
+      nil
+    end
+  end
+
   defp configuration do
     %{
       accounts: %{
diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex
index 34e23873e..1e3c9f36d 100644
--- a/lib/pleroma/web/mastodon_api/views/poll_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex
@@ -21,7 +21,10 @@ defmodule Pleroma.Web.MastodonAPI.PollView do
       votes_count: votes_count,
       voters_count: voters_count(object),
       options: options,
-      emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"])
+      emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]),
+      pleroma: %{
+        non_anonymous: object.data["nonAnonymous"] || false
+      }
     }
 
     if params[:for] do
diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex
index 026429d56..2bd857607 100644
--- a/lib/pleroma/web/mastodon_api/views/status_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/status_view.ex
@@ -184,7 +184,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
     favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
 
-    bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
+    bookmark = Activity.get_bookmark(reblogged_parent_activity, opts[:for])
+
+    bookmark_folder =
+      if bookmark != nil do
+        bookmark.folder_id
+      else
+        nil
+      end
 
     mentions =
       activity.recipients
@@ -213,7 +220,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       favourites_count: 0,
       reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
       favourited: present?(favorited),
-      bookmarked: present?(bookmarked),
+      bookmarked: present?(bookmark),
       muted: false,
       pinned: pinned?,
       sensitive: false,
@@ -227,7 +234,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       emojis: [],
       pleroma: %{
         local: activity.local,
-        pinned_at: pinned_at
+        pinned_at: pinned_at,
+        bookmark_folder: bookmark_folder
       }
     }
   end
@@ -264,7 +272,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
 
     favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
 
-    bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
+    bookmark = Activity.get_bookmark(activity, opts[:for])
+
+    bookmark_folder =
+      if bookmark != nil do
+        bookmark.folder_id
+      else
+        nil
+      end
 
     client_posted_this_activity = opts[:for] && user.id == opts[:for].id
 
@@ -418,7 +433,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       favourites_count: like_count,
       reblogged: reblogged?(activity, opts[:for]),
       favourited: present?(favorited),
-      bookmarked: present?(bookmarked),
+      bookmarked: present?(bookmark),
       muted: muted,
       pinned: pinned?,
       sensitive: sensitive,
@@ -448,7 +463,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
         emoji_reactions: emoji_reactions,
         parent_visible: visible_for_user?(reply_to, opts[:for]),
         pinned_at: pinned_at,
-        quotes_count: object.data["quotesCount"] || 0
+        quotes_count: object.data["quotesCount"] || 0,
+        bookmark_folder: bookmark_folder
       }
     }
   end
@@ -573,6 +589,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
       provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
       url: page_url,
       image: image_url,
+      image_description: rich_media["image:alt"] || "",
       title: rich_media["title"] || "",
       description: rich_media["description"] || "",
       pleroma: %{
diff --git a/lib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex b/lib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex
new file mode 100644
index 000000000..6d6e2e940
--- /dev/null
+++ b/lib/pleroma/web/pleroma_api/controllers/bookmark_folder_controller.ex
@@ -0,0 +1,68 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.BookmarkFolderController do
+  use Pleroma.Web, :controller
+
+  alias Pleroma.BookmarkFolder
+  alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+  plug(Pleroma.Web.ApiSpec.CastAndValidate)
+
+  # Note: scope not present in Mastodon: read:bookmarks
+  plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :index)
+
+  # Note: scope not present in Mastodon: write:bookmarks
+  plug(
+    OAuthScopesPlug,
+    %{scopes: ["write:bookmarks"]} when action in [:create, :update, :delete]
+  )
+
+  defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBookmarkFolderOperation
+
+  action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+  def index(%{assigns: %{user: user}} = conn, _params) do
+    with folders <- BookmarkFolder.for_user(user.id) do
+      conn
+      |> render("index.json", %{folders: folders, as: :folder})
+    end
+  end
+
+  def create(
+        %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: params}}} = conn,
+        _
+      ) do
+    with {:ok, folder} <- BookmarkFolder.create(user.id, params[:name], params[:emoji]) do
+      render(conn, "show.json", folder: folder)
+    end
+  end
+
+  def update(
+        %{
+          assigns: %{user: user},
+          private: %{open_api_spex: %{body_params: params, params: %{id: id}}}
+        } = conn,
+        _
+      ) do
+    with true <- BookmarkFolder.belongs_to_user?(id, user.id),
+         {:ok, folder} <- BookmarkFolder.update(id, params[:name], params[:emoji]) do
+      render(conn, "show.json", folder: folder)
+    else
+      false -> {:error, :forbidden}
+    end
+  end
+
+  def delete(
+        %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn,
+        _
+      ) do
+    with true <- BookmarkFolder.belongs_to_user?(id, user.id),
+         {:ok, folder} <- BookmarkFolder.delete(id) do
+      render(conn, "show.json", folder: folder)
+    else
+      false -> {:error, :forbidden}
+    end
+  end
+end
diff --git a/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex b/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex
new file mode 100644
index 000000000..12decb816
--- /dev/null
+++ b/lib/pleroma/web/pleroma_api/views/bookmark_folder_view.ex
@@ -0,0 +1,42 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.PleromaAPI.BookmarkFolderView do
+  use Pleroma.Web, :view
+
+  alias Pleroma.BookmarkFolder
+  alias Pleroma.Emoji
+  alias Pleroma.Web.Endpoint
+
+  def render("show.json", %{folder: %BookmarkFolder{} = folder}) do
+    %{
+      id: folder.id |> to_string(),
+      name: folder.name,
+      emoji: folder.emoji,
+      emoji_url: get_emoji_url(folder.emoji)
+    }
+  end
+
+  def render("index.json", %{folders: folders} = opts) do
+    render_many(folders, __MODULE__, "show.json", Map.delete(opts, :folders))
+  end
+
+  defp get_emoji_url(nil) do
+    nil
+  end
+
+  defp get_emoji_url(emoji) do
+    if Emoji.unicode?(emoji) do
+      nil
+    else
+      emoji = Emoji.get(emoji)
+
+      if emoji != nil do
+        Endpoint.url() |> URI.merge(emoji.file) |> to_string()
+      else
+        nil
+      end
+    end
+  end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index fcf6edfd7..4b5c7ca86 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -580,6 +580,11 @@ defmodule Pleroma.Web.Router do
 
       get("/backups", BackupController, :index)
       post("/backups", BackupController, :create)
+
+      get("/bookmark_folders", BookmarkFolderController, :index)
+      post("/bookmark_folders", BookmarkFolderController, :create)
+      patch("/bookmark_folders/:id", BookmarkFolderController, :update)
+      delete("/bookmark_folders/:id", BookmarkFolderController, :delete)
     end
 
     scope [] do
diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex
index 7a2210dc1..dbf40ee1b 100644
--- a/lib/pleroma/workers/background_worker.ex
+++ b/lib/pleroma/workers/background_worker.ex
@@ -40,6 +40,11 @@ defmodule Pleroma.Workers.BackgroundWorker do
     Pleroma.FollowingRelationship.move_following(origin, target)
   end
 
+  def perform(%Job{args: %{"op" => "verify_fields_links", "user_id" => user_id}}) do
+    user = User.get_by_id(user_id)
+    User.perform(:verify_fields_links, user)
+  end
+
   def perform(%Job{args: %{"op" => "delete_instance", "host" => host}}) do
     Instance.perform(:delete_instance, host)
   end
diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex
index 1dddd8d2e..8b2052c23 100644
--- a/lib/pleroma/workers/receiver_worker.ex
+++ b/lib/pleroma/workers/receiver_worker.ex
@@ -52,7 +52,8 @@ defmodule Pleroma.Workers.ReceiverWorker do
       {:error, {:reject, reason}} -> {:cancel, reason}
       {:signature, false} -> {:cancel, :invalid_signature}
       {:error, {:error, reason = "Object has been deleted"}} -> {:cancel, reason}
-      e -> e
+      {:error, _} = e -> e
+      e -> {:error, e}
     end
   end
 end
diff --git a/priv/repo/migrations/20240223165000_create_bookmark_folders.exs b/priv/repo/migrations/20240223165000_create_bookmark_folders.exs
new file mode 100644
index 000000000..016916968
--- /dev/null
+++ b/priv/repo/migrations/20240223165000_create_bookmark_folders.exs
@@ -0,0 +1,27 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Repo.Migrations.CreateBookmarkFolders do
+  use Ecto.Migration
+
+  def change do
+    create_if_not_exists table(:bookmark_folders, primary_key: false) do
+      add(:id, :uuid, primary_key: true)
+      add(:name, :string, null: false)
+      add(:emoji, :string)
+      add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
+
+      timestamps()
+    end
+
+    alter table(:bookmarks) do
+      add_if_not_exists(
+        :folder_id,
+        references(:bookmark_folders, type: :uuid, on_delete: :nilify_all)
+      )
+    end
+
+    create_if_not_exists(unique_index(:bookmark_folders, [:user_id, :name]))
+  end
+end
diff --git a/priv/static/schemas/litepub-0.1.jsonld b/priv/static/schemas/litepub-0.1.jsonld
index b499a96f5..3569165a4 100644
--- a/priv/static/schemas/litepub-0.1.jsonld
+++ b/priv/static/schemas/litepub-0.1.jsonld
@@ -2,6 +2,7 @@
     "@context": [
         "https://www.w3.org/ns/activitystreams",
         "https://w3id.org/security/v1",
+        "https://purl.archive.org/socialweb/webfinger",
         {
             "Emoji": "toot:Emoji",
             "Hashtag": "as:Hashtag",
@@ -40,7 +41,9 @@
                 "@type": "@id"
             },
             "vcard": "http://www.w3.org/2006/vcard/ns#",
-            "formerRepresentations": "litepub:formerRepresentations"
+            "formerRepresentations": "litepub:formerRepresentations",
+            "sm": "http://smithereen.software/ns#",
+            "nonAnonymous": "sm:nonAnonymous"
         }
     ]
 }
diff --git a/test/fixtures/minds-invalid-mention-post.json b/test/fixtures/minds-invalid-mention-post.json
new file mode 100644
index 000000000..ea2cb2739
--- /dev/null
+++ b/test/fixtures/minds-invalid-mention-post.json
@@ -0,0 +1 @@
+{"@context":"https://www.w3.org/ns/activitystreams","type":"Note","id":"https://www.minds.com/api/activitypub/users/1198929502760083472/entities/urn:comment:1600926863310458883:0:0:0:1600932467852709903","attributedTo":"https://www.minds.com/api/activitypub/users/1198929502760083472","content":"\u003Ca class=\u0022u-url mention\u0022 href=\u0022https://www.minds.com/lain\u0022 target=\u0022_blank\u0022\u003E@lain\u003C/a\u003E corn syrup.","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://www.minds.com/api/activitypub/users/1198929502760083472/followers","https://lain.com/users/lain"],"tag":[{"type":"Mention","href":"https://www.minds.com/api/activitypub/users/464237775479123984","name":"@lain"}],"url":"https://www.minds.com/newsfeed/1600926863310458883?focusedCommentUrn=urn:comment:1600926863310458883:0:0:0:1600932467852709903","published":"2024-02-04T17:34:03+00:00","inReplyTo":"https://lain.com/objects/36254095-c839-4167-bcc2-b361d5de9198","source":{"content":"@lain corn syrup.","mediaType":"text/plain"}}
\ No newline at end of file
diff --git a/test/fixtures/minds-pleroma-mentioned-post.json b/test/fixtures/minds-pleroma-mentioned-post.json
new file mode 100644
index 000000000..9dfa42c90
--- /dev/null
+++ b/test/fixtures/minds-pleroma-mentioned-post.json
@@ -0,0 +1 @@
+{"@context":["https://www.w3.org/ns/activitystreams","https://lain.com/schemas/litepub-0.1.jsonld",{"@language":"und"}],"actor":"https://lain.com/users/lain","attachment":[],"attributedTo":"https://lain.com/users/lain","cc":["https://lain.com/users/lain/followers"],"content":"which diet is the best for cognitive dissonance","context":"https://lain.com/contexts/98c8a130-e813-4797-8973-600e80114317","conversation":"https://lain.com/contexts/98c8a130-e813-4797-8973-600e80114317","id":"https://lain.com/objects/36254095-c839-4167-bcc2-b361d5de9198","published":"2024-02-04T17:11:23.931890Z","repliesCount":11,"sensitive":null,"source":{"content":"which diet is the best for cognitive dissonance","mediaType":"text/plain"},"summary":"","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"type":"Note"}
\ No newline at end of file
diff --git a/test/fixtures/tesla_mock/smithereen_non_anonymous_poll.json b/test/fixtures/tesla_mock/smithereen_non_anonymous_poll.json
new file mode 100644
index 000000000..2b343ea64
--- /dev/null
+++ b/test/fixtures/tesla_mock/smithereen_non_anonymous_poll.json
@@ -0,0 +1 @@
+{"type":"Question","id":"https://friends.grishka.me/posts/54642","attributedTo":"https://friends.grishka.me/users/1","content":"<p>здесь тоже можно что-то написать отдельно от опроса</p>","published":"2021-09-04T00:22:16Z","url":"https://friends.grishka.me/posts/54642","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://friends.grishka.me/users/1/followers"],"replies":{"type":"Collection","id":"https://friends.grishka.me/posts/54642/replies","first":{"type":"CollectionPage","items":[],"partOf":"https://friends.grishka.me/posts/54642/replies","next":"https://friends.grishka.me/posts/54642/replies?page=1"}},"sensitive":false,"likes":"https://friends.grishka.me/posts/54642/likes","name":"тестовый опрос","oneOf":[{"type":"Note","id":"https://friends.grishka.me/posts/54642#options/76","name":"тестовый ответ 1","replies":{"type":"Collection","id":"https://friends.grishka.me/activitypub/objects/polls/24/options/76/votes","totalItems":4,"items":[]}},{"type":"Note","id":"https://friends.grishka.me/posts/54642#options/77","name":"тестовый ответ 2","replies":{"type":"Collection","id":"https://friends.grishka.me/activitypub/objects/polls/24/options/77/votes","totalItems":4,"items":[]}},{"type":"Note","id":"https://friends.grishka.me/posts/54642#options/78","name":"тестовый ответ 3","replies":{"type":"Collection","id":"https://friends.grishka.me/activitypub/objects/polls/24/options/78/votes","totalItems":6,"items":[]}}],"votersCount":14,"nonAnonymous":true,"@context":["https://www.w3.org/ns/activitystreams",{"sensitive":"as:sensitive","toot":"http://joinmastodon.org/ns#","sm":"http://smithereen.software/ns#","votersCount":"toot:votersCount","nonAnonymous":"sm:nonAnonymous"}]}
\ No newline at end of file
diff --git a/test/fixtures/tesla_mock/smithereen_user.json b/test/fixtures/tesla_mock/smithereen_user.json
new file mode 100644
index 000000000..6468fc519
--- /dev/null
+++ b/test/fixtures/tesla_mock/smithereen_user.json
@@ -0,0 +1 @@
+{"type":"Person","id":"https://friends.grishka.me/users/1","name":"Григорий Клюшников","icon":{"type":"Image","image":{"type":"Image","url":"https://friends.grishka.me/i/6QLsOws97AWp5N_osd74C1IC1ijnFopyCBD9MSEeXNQ/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg","mediaType":"image/jpeg","width":1280,"height":960},"width":573,"height":572,"cropRegion":[0.26422762870788574,0.3766937553882599,0.7113820910453796,0.9728997349739075],"url":"https://friends.grishka.me/i/ql_49PQcETAWgY_nC-Qj63H_Oa6FyOAEoWFkUSSkUvQ/c:573:572:nowe:338:362/q:93/bG9jYWw6Ly8vcy91cGxvYWRzL2F2YXRhcnMvNTYzODRhODEwODk5ZTRjMzI4YmY4YmQwM2Q2MWM3NmMud2VicA.jpg","mediaType":"image/jpeg"},"summary":"<p>Делаю эту хрень, пытаюсь вырвать социальные сети из жадных лап корпораций</p>\n<p></p>\n<p></p>\n<p></p>\n<p></p>\n<p></p>\n<p></p>\n<p></p>\n<p>This server does NOT support direct messages. Please write me <a href=\"https://t.me/grishka\">on Telegram</a> or <a href=\"https://matrix.to/#/@grishk:matrix.org\">Matrix</a>.</p>","url":"https://friends.grishka.me/grishka","preferredUsername":"grishka","inbox":"https://friends.grishka.me/users/1/inbox","outbox":"https://friends.grishka.me/users/1/outbox","followers":"https://friends.grishka.me/users/1/followers","following":"https://friends.grishka.me/users/1/following","endpoints":{"sharedInbox":"https://friends.grishka.me/activitypub/sharedInbox","collectionSimpleQuery":"https://friends.grishka.me/users/1/collectionQuery"},"publicKey":{"id":"https://friends.grishka.me/users/1#main-key","owner":"https://friends.grishka.me/users/1","publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjlakm+i/d9ER/hIeR7KfiFW+SdLZj2SkKIeM8cmR+YFJuh9ghFqXrkFEjcaqUnAFqe5gYDNSQACnDLA8y4DnzjfGNIohKAnRoa9x6GORmfKQvcnjaTZ53S1NvUiPPyc0Pv/vfCtY/Ab0CEXe5BLqL38oZn817Jf7pBrPRTYH7m012kvwAUTT6k0Y8lPITBEG7nzYbbuGcrN9Y/RDdwE08jmBXlZ45bahRH3VNXVpQE17dCzJB+7k+iJ1R7YCoI+DuMlBYGXGE2KVk46NZTuLnOjFV9SyXfWX4/SrJM4oxev+SX2N75tQgmNZmVVHeqg2ZcbC0WCfNjJOi2HHS9MujwIDAQAB\n-----END PUBLIC KEY-----\n"},"wall":"https://friends.grishka.me/users/1/wall","firstName":"Григорий","lastName":"Клюшников","middleName":"Александрович","vcard:bday":"1993-01-22","gender":"http://schema.org#Male","supportsFriendRequests":true,"friends":"https://friends.grishka.me/users/1/friends","groups":"https://friends.grishka.me/users/1/groups","capabilities":{"supportsFriendRequests":true},"@context":["https://www.w3.org/ns/activitystreams",{"sm":"http://smithereen.software/ns#","cropRegion":{"@id":"sm:cropRegion","@container":"@list"},"wall":{"@id":"sm:wall","@type":"@id"},"collectionSimpleQuery":"sm:collectionSimpleQuery","sc":"http://schema.org#","firstName":"sc:givenName","lastName":"sc:familyName","middleName":"sc:additionalName","gender":{"@id":"sc:gender","@type":"sc:GenderType"},"maidenName":"sm:maidenName","friends":{"@id":"sm:friends","@type":"@id"},"groups":{"@id":"sm:groups","@type":"@id"},"vcard":"http://www.w3.org/2006/vcard/ns#","capabilities":"litepub:capabilities","supportsFriendRequests":"sm:supportsFriendRequests","litepub":"http://litepub.social/ns#"},"https://w3id.org/security/v1"]}
\ No newline at end of file
diff --git a/test/pleroma/bookmark_folder_test.exs b/test/pleroma/bookmark_folder_test.exs
new file mode 100644
index 000000000..c45129b0e
--- /dev/null
+++ b/test/pleroma/bookmark_folder_test.exs
@@ -0,0 +1,60 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.BookmarkFolderTest do
+  use Pleroma.DataCase, async: true
+  import Pleroma.Factory
+  alias Pleroma.BookmarkFolder
+
+  describe "create/3" do
+    test "with valid params" do
+      user = insert(:user)
+      {:ok, folder} = BookmarkFolder.create(user.id, "Read later", "🕓")
+      assert folder.user_id == user.id
+      assert folder.name == "Read later"
+      assert folder.emoji == "🕓"
+    end
+
+    test "with invalid params" do
+      {:error, changeset} = BookmarkFolder.create(nil, "", "not an emoji")
+      refute changeset.valid?
+
+      assert changeset.errors == [
+               emoji: {"Invalid emoji", []},
+               user_id: {"can't be blank", [validation: :required]},
+               name: {"can't be blank", [validation: :required]}
+             ]
+    end
+  end
+
+  test "update/3" do
+    user = insert(:user)
+    {:ok, folder} = BookmarkFolder.create(user.id, "Read ltaer")
+    {:ok, folder} = BookmarkFolder.update(folder.id, "Read later")
+    assert folder.name == "Read later"
+  end
+
+  test "for_user/1" do
+    user = insert(:user)
+    other_user = insert(:user)
+
+    {:ok, _} = BookmarkFolder.create(user.id, "Folder 1")
+    {:ok, _} = BookmarkFolder.create(user.id, "Folder 2")
+    {:ok, _} = BookmarkFolder.create(other_user.id, "Folder 3")
+
+    folders = BookmarkFolder.for_user(user.id)
+
+    assert length(folders) == 2
+  end
+
+  test "belongs_to_user?/2" do
+    user = insert(:user)
+    other_user = insert(:user)
+
+    {:ok, folder} = BookmarkFolder.create(user.id, "Folder")
+
+    assert true == BookmarkFolder.belongs_to_user?(folder.id, user.id)
+    assert false == BookmarkFolder.belongs_to_user?(folder.id, other_user.id)
+  end
+end
diff --git a/test/pleroma/bookmark_test.exs b/test/pleroma/bookmark_test.exs
index 57144ded3..a2ed24c26 100644
--- a/test/pleroma/bookmark_test.exs
+++ b/test/pleroma/bookmark_test.exs
@@ -6,15 +6,17 @@ defmodule Pleroma.BookmarkTest do
   use Pleroma.DataCase, async: true
   import Pleroma.Factory
   alias Pleroma.Bookmark
+  alias Pleroma.BookmarkFolder
   alias Pleroma.Web.CommonAPI
 
-  describe "create/2" do
+  describe "create/3" do
     test "with valid params" do
       user = insert(:user)
       {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"})
       {:ok, bookmark} = Bookmark.create(user.id, activity.id)
       assert bookmark.user_id == user.id
       assert bookmark.activity_id == activity.id
+      assert bookmark.folder_id == nil
     end
 
     test "with invalid params" do
@@ -26,6 +28,19 @@ defmodule Pleroma.BookmarkTest do
                activity_id: {"can't be blank", [validation: :required]}
              ]
     end
+
+    test "update existing bookmark folder" do
+      user = insert(:user)
+      {:ok, activity} = CommonAPI.post(user, %{status: "Some cool information"})
+
+      {:ok, bookmark} = Bookmark.create(user.id, activity.id)
+      assert bookmark.folder_id == nil
+
+      {:ok, bookmark_folder} = BookmarkFolder.create(user.id, "Read later")
+
+      {:ok, bookmark} = Bookmark.create(user.id, activity.id, bookmark_folder.id)
+      assert bookmark.folder_id == bookmark_folder.id
+    end
   end
 
   describe "destroy/2" do
diff --git a/test/pleroma/search/database_search_test.exs b/test/pleroma/search/database_search_test.exs
index 6c47ff425..d8dd09063 100644
--- a/test/pleroma/search/database_search_test.exs
+++ b/test/pleroma/search/database_search_test.exs
@@ -35,21 +35,6 @@ defmodule Pleroma.Search.DatabaseSearchTest do
     assert [] = Search.search(nil, "wednesday")
   end
 
-  test "using plainto_tsquery on postgres < 11" do
-    old_version = :persistent_term.get({Pleroma.Repo, :postgres_version})
-    :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0)
-    on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end)
-
-    user = insert(:user)
-    {:ok, post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"})
-    {:ok, _post2} = CommonAPI.post(user, %{status: "it's wednesday my bros"})
-
-    # plainto doesn't understand complex queries
-    assert [result] = Search.search(nil, "wednesday -dudes")
-
-    assert result.id == post.id
-  end
-
   test "using websearch_to_tsquery" do
     user = insert(:user)
     {:ok, _post} = CommonAPI.post(user, %{status: "it's wednesday my dudes"})
diff --git a/test/pleroma/user/backup_test.exs b/test/pleroma/user/backup_test.exs
index 0ac57e334..5503d15bc 100644
--- a/test/pleroma/user/backup_test.exs
+++ b/test/pleroma/user/backup_test.exs
@@ -166,6 +166,7 @@ defmodule Pleroma.User.BackupTest do
 
   test "it creates a zip archive with user data" do
     user = insert(:user, %{nickname: "cofe", name: "Cofe", ap_id: "http://cofe.io/users/cofe"})
+    %{ap_id: other_ap_id} = other_user = insert(:user)
 
     {:ok, %{object: %{data: %{"id" => id1}}} = status1} =
       CommonAPI.post(user, %{status: "status1"})
@@ -182,6 +183,8 @@ defmodule Pleroma.User.BackupTest do
     Bookmark.create(user.id, status2.id)
     Bookmark.create(user.id, status3.id)
 
+    CommonAPI.follow(user, other_user)
+
     assert {:ok, backup} = user |> Backup.new() |> Repo.insert()
     assert {:ok, path} = Backup.export(backup, self())
     assert {:ok, zipfile} = :zip.zip_open(String.to_charlist(path), [:memory])
@@ -261,6 +264,16 @@ defmodule Pleroma.User.BackupTest do
              "type" => "OrderedCollection"
            } = Jason.decode!(json)
 
+    assert {:ok, {'following.json', json}} = :zip.zip_get('following.json', zipfile)
+
+    assert %{
+             "@context" => "https://www.w3.org/ns/activitystreams",
+             "id" => "following.json",
+             "orderedItems" => [^other_ap_id],
+             "totalItems" => 1,
+             "type" => "OrderedCollection"
+           } = Jason.decode!(json)
+
     :zip.zip_close(zipfile)
     File.rm!(path)
   end
diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs
index 15809ad63..a93f81659 100644
--- a/test/pleroma/user_test.exs
+++ b/test/pleroma/user_test.exs
@@ -2928,4 +2928,51 @@ defmodule Pleroma.UserTest do
       refute User.endorses?(user, pinned_user)
     end
   end
+
+  test "it checks fields links for a backlink" do
+    user = insert(:user, ap_id: "https://social.example.org/users/lain")
+
+    fields = [
+      %{"name" => "Link", "value" => "http://example.com/rel_me/null"},
+      %{"name" => "Verified link", "value" => "http://example.com/rel_me/link"},
+      %{"name" => "Not a link", "value" => "i'm not a link"}
+    ]
+
+    user
+    |> User.update_and_set_cache(%{raw_fields: fields})
+
+    ObanHelpers.perform_all()
+
+    user = User.get_cached_by_id(user.id)
+
+    assert [
+             %{"verified_at" => nil},
+             %{"verified_at" => verified_at},
+             %{"verified_at" => nil}
+           ] = user.fields
+
+    assert is_binary(verified_at)
+  end
+
+  test "updating fields does not invalidate previously validated links" do
+    user = insert(:user, ap_id: "https://social.example.org/users/lain")
+
+    user
+    |> User.update_and_set_cache(%{
+      raw_fields: [%{"name" => "verified link", "value" => "http://example.com/rel_me/link"}]
+    })
+
+    ObanHelpers.perform_all()
+
+    %User{fields: [%{"verified_at" => verified_at}]} = user = User.get_cached_by_id(user.id)
+
+    user
+    |> User.update_and_set_cache(%{
+      raw_fields: [%{"name" => "Verified link", "value" => "http://example.com/rel_me/link"}]
+    })
+
+    user = User.get_cached_by_id(user.id)
+
+    assert [%{"verified_at" => ^verified_at}] = user.fields
+  end
 end
diff --git a/test/pleroma/web/activity_pub/mrf/force_mention_test.exs b/test/pleroma/web/activity_pub/mrf/force_mention_test.exs
new file mode 100644
index 000000000..b026bab66
--- /dev/null
+++ b/test/pleroma/web/activity_pub/mrf/force_mention_test.exs
@@ -0,0 +1,73 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionTest do
+  use Pleroma.DataCase
+  require Pleroma.Constants
+
+  alias Pleroma.Web.ActivityPub.MRF.ForceMention
+
+  import Pleroma.Factory
+
+  test "adds mention to a reply" do
+    lain =
+      insert(:user, ap_id: "https://lain.com/users/lain", nickname: "lain@lain.com", local: false)
+
+    niobleoum =
+      insert(:user,
+        ap_id: "https://www.minds.com/api/activitypub/users/1198929502760083472",
+        nickname: "niobleoum@minds.com",
+        local: false
+      )
+
+    status = File.read!("test/fixtures/minds-pleroma-mentioned-post.json") |> Jason.decode!()
+
+    status_activity = %{
+      "type" => "Create",
+      "actor" => lain.ap_id,
+      "object" => status
+    }
+
+    Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(status_activity)
+
+    reply = File.read!("test/fixtures/minds-invalid-mention-post.json") |> Jason.decode!()
+
+    reply_activity = %{
+      "type" => "Create",
+      "actor" => niobleoum.ap_id,
+      "object" => reply
+    }
+
+    {:ok, %{"object" => %{"tag" => tag}}} = ForceMention.filter(reply_activity)
+
+    assert Enum.find(tag, fn %{"href" => href} -> href == lain.ap_id end)
+  end
+
+  test "adds mention to a quote" do
+    user1 = insert(:user, ap_id: "https://misskey.io/users/83ssedkv53")
+    user2 = insert(:user, ap_id: "https://misskey.io/users/7rkrarq81i")
+
+    status = File.read!("test/fixtures/tesla_mock/misskey.io_8vs6wxufd0.json") |> Jason.decode!()
+
+    status_activity = %{
+      "type" => "Create",
+      "actor" => user1.ap_id,
+      "object" => status
+    }
+
+    Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(status_activity)
+
+    quote_post = File.read!("test/fixtures/quote_post/misskey_quote_post.json") |> Jason.decode!()
+
+    quote_activity = %{
+      "type" => "Create",
+      "actor" => user2.ap_id,
+      "object" => quote_post
+    }
+
+    {:ok, %{"object" => %{"tag" => tag}}} = ForceMention.filter(quote_activity)
+
+    assert Enum.find(tag, fn %{"href" => href} -> href == user1.ap_id end)
+  end
+end
diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs
index 7aa06a5c4..870f1f77a 100644
--- a/test/pleroma/web/activity_pub/publisher_test.exs
+++ b/test/pleroma/web/activity_pub/publisher_test.exs
@@ -25,6 +25,17 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
 
   setup_all do: clear_config([:instance, :federating], true)
 
+  describe "should_federate?/1" do
+    test "it returns false when the inbox is nil" do
+      refute Publisher.should_federate?(nil, false)
+      refute Publisher.should_federate?(nil, true)
+    end
+
+    test "it returns true when public is true" do
+      assert Publisher.should_federate?(false, true)
+    end
+  end
+
   describe "gather_webfinger_links/1" do
     test "it returns links" do
       user = insert(:user)
@@ -205,6 +216,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
       refute called(Instances.set_reachable(inbox))
     end
 
+    @tag capture_log: true
     test_with_mock "calls `Instances.set_unreachable` on target inbox on non-2xx HTTP response code",
                    Instances,
                    [:passthrough],
diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs
index f9068db07..c75149dab 100644
--- a/test/pleroma/web/activity_pub/views/user_view_test.exs
+++ b/test/pleroma/web/activity_pub/views/user_view_test.exs
@@ -91,6 +91,13 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do
     assert %{"alsoKnownAs" => ^akas} = UserView.render("user.json", %{user: user})
   end
 
+  test "renders full nickname" do
+    clear_config([Pleroma.Web.WebFinger, :domain], "plemora.dev")
+
+    user = insert(:user, nickname: "user")
+    assert %{"webfinger" => "acct:user@plemora.dev"} = UserView.render("user.json", %{user: user})
+  end
+
   describe "endpoints" do
     test "local users have a usable endpoints structure" do
       user = insert(:user)
diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
index 8ffcff9f3..f91c0283b 100644
--- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs
@@ -107,6 +107,18 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do
              |> json_response_and_validate_schema(200)
   end
 
+  test "get instance contact information", %{conn: conn} do
+    user = insert(:user, %{local: true})
+
+    clear_config([:instance, :contact_username], user.nickname)
+
+    conn = get(conn, "/api/v1/instance")
+
+    assert result = json_response_and_validate_schema(conn, 200)
+
+    assert result["contact_account"]["id"] == user.id
+  end
+
   test "get instance information v2", %{conn: conn} do
     clear_config([:auth, :oauth_consumer_strategies], [])
 
diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
index b05487abe..ad4144da4 100644
--- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
@@ -322,26 +322,20 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
     end
 
     test "search fetches remote statuses and prefers them over other results", %{conn: conn} do
-      old_version = :persistent_term.get({Pleroma.Repo, :postgres_version})
-      :persistent_term.put({Pleroma.Repo, :postgres_version}, 10.0)
-      on_exit(fn -> :persistent_term.put({Pleroma.Repo, :postgres_version}, old_version) end)
+      {:ok, %{id: activity_id}} =
+        CommonAPI.post(insert(:user), %{
+          status: "check out http://mastodon.example.org/@admin/99541947525187367"
+        })
 
-      capture_log(fn ->
-        {:ok, %{id: activity_id}} =
-          CommonAPI.post(insert(:user), %{
-            status: "check out http://mastodon.example.org/@admin/99541947525187367"
-          })
+      %{"url" => result_url, "id" => result_id} =
+        conn
+        |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
+        |> json_response_and_validate_schema(200)
+        |> Map.get("statuses")
+        |> List.first()
 
-        results =
-          conn
-          |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
-          |> json_response_and_validate_schema(200)
-
-        assert [
-                 %{"url" => "http://mastodon.example.org/@admin/99541947525187367"},
-                 %{"id" => ^activity_id}
-               ] = results["statuses"]
-      end)
+      refute match?(^result_id, activity_id)
+      assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367")
     end
 
     test "search doesn't show statuses that it shouldn't", %{conn: conn} do
diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
index 2a64cac5f..003619a38 100644
--- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs
@@ -1717,6 +1717,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
 
       card_data = %{
         "image" => "http://ia.media-imdb.com/images/rock.jpg",
+        "image_description" => "",
         "provider_name" => "example.com",
         "provider_url" => "https://example.com",
         "title" => "The Rock",
@@ -1770,6 +1771,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
                "title" => "Pleroma",
                "description" => "",
                "image" => nil,
+               "image_description" => "",
                "provider_name" => "example.com",
                "provider_url" => "https://example.com",
                "url" => "https://example.com/ogp-missing-data",
@@ -1828,6 +1830,60 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
              json_response_and_validate_schema(bookmarks, 200)
   end
 
+  test "bookmark folders" do
+    %{conn: conn, user: user} = oauth_access(["write:bookmarks", "read:bookmarks"])
+
+    {:ok, folder} = Pleroma.BookmarkFolder.create(user.id, "folder")
+    author = insert(:user)
+
+    folder_bookmarks_uri = "/api/v1/bookmarks?folder_id=#{folder.id}"
+
+    {:ok, activity1} = CommonAPI.post(author, %{status: "heweoo?"})
+    {:ok, activity2} = CommonAPI.post(author, %{status: "heweoo!"})
+
+    # Add bookmark with a folder
+    response =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/statuses/#{activity1.id}/bookmark", %{folder_id: folder.id})
+
+    assert json_response_and_validate_schema(response, 200)["bookmarked"] == true
+
+    assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] ==
+             folder.id
+
+    response =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/statuses/#{activity2.id}/bookmark")
+
+    assert json_response_and_validate_schema(response, 200)["bookmarked"] == true
+    assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] == nil
+
+    bookmarks =
+      get(conn, folder_bookmarks_uri)
+      |> json_response_and_validate_schema(200)
+
+    assert length(bookmarks) == 1
+
+    # Update folder for existing bookmark
+    response =
+      conn
+      |> put_req_header("content-type", "application/json")
+      |> post("/api/v1/statuses/#{activity2.id}/bookmark", %{folder_id: folder.id})
+
+    assert json_response_and_validate_schema(response, 200)["bookmarked"] == true
+
+    assert json_response_and_validate_schema(response, 200)["pleroma"]["bookmark_folder"] ==
+             folder.id
+
+    bookmarks =
+      get(conn, folder_bookmarks_uri)
+      |> json_response_and_validate_schema(200)
+
+    assert length(bookmarks) == 2
+  end
+
   describe "conversation muting" do
     setup do: oauth_access(["write:mutes"])
 
diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs
index cf26cd9a6..bea0cae69 100644
--- a/test/pleroma/web/mastodon_api/update_credentials_test.exs
+++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs
@@ -511,10 +511,15 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do
         |> json_response_and_validate_schema(200)
 
       assert account_data["fields"] == [
-               %{"name" => "<a href=\"http://google.com\">foo</a>", "value" => "bar"},
+               %{
+                 "name" => "<a href=\"http://google.com\">foo</a>",
+                 "value" => "bar",
+                 "verified_at" => nil
+               },
                %{
                  "name" => "link.io",
-                 "value" => ~S(<a href="http://cofe.io" rel="ugc">cofe.io</a>)
+                 "value" => ~S(<a href="http://cofe.io" rel="ugc">cofe.io</a>),
+                 "verified_at" => nil
                }
              ]
 
@@ -573,8 +578,8 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do
         |> json_response_and_validate_schema(200)
 
       assert account_data["fields"] == [
-               %{"name" => ":firefox:", "value" => "is best 2hu"},
-               %{"name" => "they wins", "value" => ":blank:"}
+               %{"name" => ":firefox:", "value" => "is best 2hu", "verified_at" => nil},
+               %{"name" => "they wins", "value" => ":blank:", "verified_at" => nil}
              ]
 
       assert account_data["source"]["fields"] == [
@@ -602,10 +607,11 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do
         |> json_response_and_validate_schema(200)
 
       assert account["fields"] == [
-               %{"name" => "foo", "value" => "bar"},
+               %{"name" => "foo", "value" => "bar", "verified_at" => nil},
                %{
                  "name" => "link",
-                 "value" => ~S(<a href="http://cofe.io" rel="ugc">http://cofe.io</a>)
+                 "value" => ~S(<a href="http://cofe.io" rel="ugc">http://cofe.io</a>),
+                 "verified_at" => nil
                }
              ]
 
@@ -627,7 +633,7 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do
         |> json_response_and_validate_schema(200)
 
       assert account["fields"] == [
-               %{"name" => "foo", "value" => ""}
+               %{"name" => "foo", "value" => "", "verified_at" => nil}
              ]
     end
 
diff --git a/test/pleroma/web/mastodon_api/views/poll_view_test.exs b/test/pleroma/web/mastodon_api/views/poll_view_test.exs
index a73d862fd..3aa73c224 100644
--- a/test/pleroma/web/mastodon_api/views/poll_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/poll_view_test.exs
@@ -43,7 +43,8 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
         %{title: "why are you even asking?", votes_count: 0}
       ],
       votes_count: 0,
-      voters_count: 0
+      voters_count: 0,
+      pleroma: %{non_anonymous: false}
     }
 
     result = PollView.render("show.json", %{object: object})
@@ -165,4 +166,11 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
              ]
            } = PollView.render("show.json", %{object: object})
   end
+
+  test "that poll is non anonymous" do
+    object = Object.normalize("https://friends.grishka.me/posts/54642", fetch: true)
+    result = PollView.render("show.json", %{object: object})
+
+    assert result[:pleroma][:non_anonymous] == true
+  end
 end
diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs
index 47da5b048..4a53406a0 100644
--- a/test/pleroma/web/mastodon_api/views/status_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs
@@ -341,7 +341,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
         emoji_reactions: [],
         parent_visible: false,
         pinned_at: nil,
-        quotes_count: 0
+        quotes_count: 0,
+        bookmark_folder: nil
       }
     }
 
@@ -772,6 +773,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
       page_url = "http://example.com"
 
       card = %{
+        "image:alt" => "Example image description",
         url: page_url,
         site_name: "Example site name",
         title: "Example website",
@@ -779,7 +781,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
         description: "Example description"
       }
 
-      %{provider_name: "example.com"} =
+      %{provider_name: "example.com", image_description: "Example image description"} =
         StatusView.render("card.json", %{page_url: page_url, rich_media: card})
     end
 
diff --git a/test/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs
new file mode 100644
index 000000000..9bd90ed2e
--- /dev/null
+++ b/test/pleroma/web/pleroma_api/controllers/bookmark_folder_controller_test.exs
@@ -0,0 +1,161 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+defmodule Pleroma.Web.PleromaAPI.BookmarkFolderControllerTest do
+  use Pleroma.Web.ConnCase
+
+  alias Pleroma.BookmarkFolder
+  # alias Pleroma.Object
+  # alias Pleroma.Tests.Helpers
+  # alias Pleroma.UnstubbedConfigMock, as: ConfigMock
+  # alias Pleroma.User
+  # alias Pleroma.Web.ActivityPub.ActivityPub
+  # alias Pleroma.Web.CommonAPI
+
+  # import Mox
+  import Pleroma.Factory
+
+  describe "GET /api/v1/pleroma/bookmark_folders" do
+    setup do: oauth_access(["read:bookmarks"])
+
+    test "it lists bookmark folders", %{conn: conn, user: user} do
+      {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder")
+
+      folder_id = folder.id
+
+      result =
+        conn
+        |> get("/api/v1/pleroma/bookmark_folders")
+        |> json_response_and_validate_schema(200)
+
+      assert [
+               %{
+                 "id" => ^folder_id,
+                 "name" => "Bookmark folder",
+                 "emoji" => nil,
+                 "emoji_url" => nil
+               }
+             ] = result
+    end
+  end
+
+  describe "POST /api/v1/pleroma/bookmark_folders" do
+    setup do: oauth_access(["write:bookmarks"])
+
+    test "it creates a bookmark folder", %{conn: conn} do
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/pleroma/bookmark_folders", %{
+          name: "Bookmark folder",
+          emoji: "📁"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert %{
+               "name" => "Bookmark folder",
+               "emoji" => "📁",
+               "emoji_url" => nil
+             } = result
+    end
+
+    test "it creates a bookmark folder with custom emoji", %{conn: conn} do
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/pleroma/bookmark_folders", %{
+          name: "Bookmark folder",
+          emoji: ":firefox:"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert %{
+               "name" => "Bookmark folder",
+               "emoji" => ":firefox:",
+               "emoji_url" => "http://localhost:4001/emoji/Firefox.gif"
+             } = result
+    end
+
+    test "it returns error for invalid emoji", %{conn: conn} do
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> post("/api/v1/pleroma/bookmark_folders", %{
+          name: "Bookmark folder",
+          emoji: "not an emoji"
+        })
+        |> json_response_and_validate_schema(422)
+
+      assert %{"error" => "Invalid emoji"} = result
+    end
+  end
+
+  describe "PATCH /api/v1/pleroma/bookmark_folders/:id" do
+    setup do: oauth_access(["write:bookmarks"])
+
+    test "it updates a bookmark folder", %{conn: conn, user: user} do
+      {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder")
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}", %{
+          name: "bookmark folder"
+        })
+        |> json_response_and_validate_schema(200)
+
+      assert %{
+               "name" => "bookmark folder"
+             } = result
+    end
+
+    test "it returns error when updating others' folders", %{conn: conn} do
+      other_user = insert(:user)
+
+      {:ok, folder} = BookmarkFolder.create(other_user.id, "Bookmark folder")
+
+      result =
+        conn
+        |> put_req_header("content-type", "application/json")
+        |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}", %{
+          name: "bookmark folder"
+        })
+        |> json_response_and_validate_schema(403)
+
+      assert %{
+               "error" => "Access denied"
+             } = result
+    end
+  end
+
+  describe "DELETE /api/v1/pleroma/bookmark_folders/:id" do
+    setup do: oauth_access(["write:bookmarks"])
+
+    test "it deleting a bookmark folder", %{conn: conn, user: user} do
+      {:ok, folder} = BookmarkFolder.create(user.id, "Bookmark folder")
+
+      assert conn
+             |> delete("/api/v1/pleroma/bookmark_folders/#{folder.id}")
+             |> json_response_and_validate_schema(200)
+
+      folders = BookmarkFolder.for_user(user.id)
+
+      assert length(folders) == 0
+    end
+
+    test "it returns error when deleting others' folders", %{conn: conn} do
+      other_user = insert(:user)
+
+      {:ok, folder} = BookmarkFolder.create(other_user.id, "Bookmark folder")
+
+      result =
+        conn
+        |> patch("/api/v1/pleroma/bookmark_folders/#{folder.id}")
+        |> json_response_and_validate_schema(403)
+
+      assert %{
+               "error" => "Access denied"
+             } = result
+    end
+  end
+end
diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex
index 771336b6f..0ee33d579 100644
--- a/test/support/http_request_mock.ex
+++ b/test/support/http_request_mock.ex
@@ -1503,6 +1503,24 @@ defmodule HttpRequestMock do
     {:ok, %Tesla.Env{status: 200, body: "hello"}}
   end
 
+  def get("https://friends.grishka.me/posts/54642", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/smithereen_non_anonymous_poll.json"),
+       headers: activitypub_object_headers()
+     }}
+  end
+
+  def get("https://friends.grishka.me/users/1", _, _, _) do
+    {:ok,
+     %Tesla.Env{
+       status: 200,
+       body: File.read!("test/fixtures/tesla_mock/smithereen_user.json"),
+       headers: activitypub_object_headers()
+     }}
+  end
+
   def get(url, query, body, headers) do
     {:error,
      "Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{inspect(headers)}"}
diff --git a/test/test_helper.exs b/test/test_helper.exs
index e65f7c1d1..a117584ae 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -4,6 +4,8 @@
 
 Code.put_compiler_option(:warnings_as_errors, true)
 
+ExUnit.configure(max_cases: System.schedulers_online())
+
 ExUnit.start(exclude: [:federated, :erratic])
 
 if match?({:unix, :darwin}, :os.type()) do
diff --git a/uploads/.gitignore b/uploads/.gitignore
deleted file mode 100644
index 523e584a7..000000000
--- a/uploads/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Git will ignore everything in this directory except this file.
-*
-!.gitignore