Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file. It uses the Web Share API where the browser has it, which is the share sheet on a phone, and copies the link where it does not. A file is shared by the publisher's address, not /media/, which only someone signed in here can open. Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one gives the subscription away. An address that looks like that asks first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ function renderFeed(){
|
||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
@@ -120,6 +121,7 @@ function renderGroup(f,kids){
|
||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
@@ -166,6 +168,7 @@ async function feedAction(a,f){
|
||||
}catch(e){ toast(e.message,true); }
|
||||
}
|
||||
if(a==='settings') settingsModal(f);
|
||||
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||
if(a==='dl') downloadLatestModal(f);
|
||||
}
|
||||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||
|
||||
@@ -249,7 +249,8 @@ function showDetail(e){
|
||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
||||
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
||||
</div>
|
||||
${narrow?encs:''}
|
||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||
@@ -273,6 +274,8 @@ function encBox(x){
|
||||
: 'Delete file';
|
||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||
if(x.path && !isPlayable(x)){
|
||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||
return `<div class="encbox">
|
||||
@@ -280,6 +283,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||
${saveBtn}
|
||||
${shareBtn}
|
||||
${delBtn}
|
||||
</div>`;
|
||||
}
|
||||
@@ -291,6 +295,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||
${saveBtn}
|
||||
${shareBtn}
|
||||
${delBtn}
|
||||
</div>`;
|
||||
}
|
||||
@@ -303,6 +308,7 @@ function encBox(x){
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||
${shareBtn}
|
||||
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -314,6 +320,7 @@ async function epAction(a: string, e, el, encId?: number){
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
if(a==='get'){
|
||||
|
||||
@@ -10,6 +10,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
|
||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||
const ICON={
|
||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||
@@ -92,6 +93,27 @@ async function copyText(text,btn){
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
|
||||
/// Asks first when the address looks like it carries your own access to a paid or private
|
||||
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
|
||||
/// gets it your subscription (issue #48).
|
||||
///
|
||||
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||
/// without asking; add its pattern here when one turns up.
|
||||
async function share(title: string, url: string, btn?){
|
||||
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||
if(navigator.share){
|
||||
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||
try{ await navigator.share({title, url}); }
|
||||
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||
return;
|
||||
}
|
||||
await copyText(url,btn);
|
||||
toast('Link copied');
|
||||
}
|
||||
|
||||
function toast(msg: string, bad?: boolean){
|
||||
const t=document.createElement('div');
|
||||
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||
|
||||
Reference in New Issue
Block a user