mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 01:10:43 +08:00
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>
103 lines
3.3 KiB
Ruby
Vendored
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
|