0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 01:10:43 +08:00
discourse/spec/system/share_target_spec.rb
Rafael dos Santos Silva 6167f3bbac
FEATURE: Accept shared files via the Web Share Target (#41030)
Previously, Discourse only registered as a Level 1 Web Share Target — a
`GET` text-only handler that could not receive images or other files
from the OS share sheet.

This change registers a modern `POST`/`multipart/form-data` share
target. The service worker intercepts the incoming share, stashes the
shared title, text, url, and files in the Cache API, and redirects to a
`share-target` route that opens a modal asking whether to start a new
topic, a new message, or save the content to add to the next reply.
Topics and messages open the composer pre-filled with the shared text
and uploaded files; the reply option buffers the content and injects it
into the next reply composer that opens.

### Notes
- The file `accept` list covers images, video, audio, PDFs, and common
document types; the server still enforces `authorized_extensions`.
- Adds a `composer:uploader-ready` app event so shared files are only
handed to the uploader once it is bound, avoiding a race on cold
composer open.
- Only Android Chromium-based browsers currently implement file-capable
share targets; iOS has no Web Share Target support.

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2026-06-18 17:35:09 -03:00

103 lines
3.3 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe "Share target" do
fab!(:user)
let(:composer) { PageObjects::Components::Composer.new }
let(:share_target_modal) { PageObjects::Modals::ShareTarget.new }
before { sign_in(user) }
it "lets the user start a new topic from shared content", mobile: true do
title = "Shared topic title"
text = "Shared text from another app"
url = "https://example.com/shared-link"
expected_body = "#{text}\n\n#{url}"
visit("/")
seed_shared_content(title:, text:, url:, files: [shared_image_file])
visit("/share-target")
expect(share_target_modal).to be_open
expect(share_target_modal).to have_preview_text(text)
expect(share_target_modal).to have_preview_text(url)
share_target_modal.click_new_topic
expect(composer).to be_opened
expect(composer).to have_input_title(title)
wait_for(timeout: 5) { composer.composer_input.value.include?("upload://") }
expect(composer).to have_no_in_progress_uploads
composer_value = composer.composer_input.value
expect(composer_value).to include(expected_body)
expect(composer_value).to match(%r{!\[shared-image\|.*\]\(upload://.*\)})
end
def seed_shared_content(title:, text:, url:, files:)
page.execute_script(<<~JS)
window.__shareTargetCacheSeeded = false;
window.__shareTargetCacheSeedError = "";
(async () => {
await caches.delete("discourse-share-target");
const cache = await caches.open("discourse-share-target");
const files = #{files.to_json};
for (const [index, file] of files.entries()) {
const binary = atob(file.base64);
const bytes = new Uint8Array(binary.length);
for (let byteIndex = 0; byteIndex < binary.length; byteIndex++) {
bytes[byteIndex] = binary.charCodeAt(byteIndex);
}
await cache.put(
new Request(file.key),
new Response(
new Blob([bytes], { type: file.type }),
{
headers: {
"content-type": file.type,
"x-share-filename": encodeURIComponent(file.name || `shared-file-${index}`),
},
}
)
);
}
await cache.put(
new Request("/__discourse_share_target__/meta"),
new Response(
JSON.stringify({
title: #{title.to_json},
text: #{text.to_json},
url: #{url.to_json},
files: files.map(({ key, name, type }) => ({ key, name, type })),
}),
{ headers: { "content-type": "application/json" } }
)
);
window.__shareTargetCacheSeeded = true;
})().catch((error) => {
window.__shareTargetCacheSeedError = `${error.name}: ${error.message}`;
});
JS
wait_for(timeout: 5) { page.evaluate_script("window.__shareTargetCacheSeeded === true") }
expect(page.evaluate_script("window.__shareTargetCacheSeedError")).to eq("")
end
def shared_image_file
{
key: "/__discourse_share_target__/file-0",
name: "shared-image.png",
type: "image/png",
base64:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
}
end
end