Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ Default reminders wait for required approval and payment. New default reminders

## Shared media

Choose **Photo sharing only** on the Mini App Create page, or use **Photo sharing** on the bot keyboard, to create a standalone collection with just a name. No event date, tickets or RSVP is needed. Each collection has a **viewer link** for browsing and a separate **contributor link** for browsing and uploading photos, videos and files. Share the appropriate link for the access you want to give. Opening either link does not add the person to an event guest list.

The collection owner can turn **Show who uploaded media** on or off in the Mini App or with the bot's **Uploader names** button. Names are shown by default. Turning this off hides uploader attribution in gallery responses, previews and bot captions, including existing uploads; internal upload records remain intact. User-written captions, filenames and file contents remain visible.

Hosts can separately allow guest uploads and gallery browsing. Collect photos, videos, and files through Telegram, then use **Shared media** in the Mini App to browse previews. Supported images sent as documents and videos can use Telegram thumbnails or supported file previews.

The gallery supports **Save** and **Send to my Telegram**. Sending delivers the chosen item to the user's bot chat. Mini App file downloads are capped at 20 MB; larger files can be sent to Telegram for retrieval in chat. Preview and download support depends on the file format and Telegram's available file data.
Expand Down
47 changes: 47 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function dateInZone(instant, zone) {
return `${parts.year}-${parts.month}-${parts.day}`;
}
function format(e, zone = selectedZone()) {
if(e.kind==='photos')return 'Photo collection';
if (!e.startsAt) return e.when + '\nTimezone not set — shown as entered by the organiser.';
let text = new Intl.DateTimeFormat('en-AU', { timeZone: zone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date(e.startsAt)) + ` (${zone})`;
if (e.endsAt) {
Expand All @@ -108,6 +109,45 @@ function format(e, zone = selectedZone()) {
return text;
}
function openTelegram(url) { if (tg?.initData) tg.openTelegramLink(url); else window.open(url, '_blank', 'noopener'); }
function appendPhotoLinks(node,event){
if(event.isOwner){
const label=element('label','','check-option'),input=element('input');input.type='checkbox';input.checked=event.showUploaders!==false;
input.onchange=async()=>{
input.disabled=true;
try{const result=await api(`collections/${event.id}/settings`,{showUploaders:input.checked});Object.assign(event,result.event);await refresh();notice(input.checked?'Uploader names are shown.':'Uploader names are hidden.');}
catch(error){input.checked=event.showUploaders!==false;notice(error.message);}
finally{input.disabled=false;}
};
label.append(input,element('span','Show who uploaded media'));node.append(label);
}
for(const [role,link] of Object.entries(event.photoLinks || {})){
node.append(action('Share '+role+' link',()=>openTelegram('https://t.me/share/url?url='+encodeURIComponent(link))));
const label=element('label',role==='viewer'?'Viewer — browse only':'Contributor — browse and add media');
const input=element('input');input.value=link;input.readOnly=true;input.setAttribute('aria-label',role+' link');input.onclick=()=>input.select();
label.append(input);node.append(label);
}
}
function setupPhotoCreation(){
$('photo-create-panel').ontoggle=()=>{
if(compactPicker || activeEvent)return;
const photos=$('photo-create-panel').open;
$('event-form').hidden=photos;
$('success').hidden=true;
$('form-title').textContent=photos?'Share your memories.':'Make a plan.';
$('form-description').textContent=photos?'Create a collection and choose which link to share.':'Pick a date. Share an invite. Let the good times follow.';
};
let photoRequestId=crypto.randomUUID();
$('photo-form').onsubmit=async event=>{
event.preventDefault();const button=$('photo-create');if(button.disabled)return;
button.disabled=true;$('photo-error').hidden=true;
try{
const result=await api('collections',{title:$('photo-title').value,showUploaders:$('photo-show-uploaders').checked,requestId:photoRequestId});
const links=$('photo-links');links.replaceChildren(element('h3',result.event.title),action('Open collection',()=>openGallery(result.event.id)));appendPhotoLinks(links,result.event);links.hidden=false;
await refresh();photoRequestId=crypto.randomUUID();
}catch(error){$('photo-error').textContent=error.message;$('photo-error').hidden=false;}
finally{button.disabled=false;}
};
}
let guestListBaseline='';
function invitationGuestLine(guest){return guest.name+(guest.participantMode==='ask' ? ' = ?' : guest.participants ? ' = '+guest.participants+(guest.participantMode==='confirm' ? '!' : guest.participantMode==='fixed' ? '*' : '') : '');}
function guestNamesIn(text){return text.split('\n').map(line=>line.split('=')[0].trim()).filter(Boolean);}
Expand Down Expand Up @@ -385,6 +425,10 @@ function renderEvents() {
for (const e of events) {
if (listFilter === 'all' && e.group !== lastGroup) { list.append(element('h2', e.group, 'event-group')); lastGroup = e.group; }
const card = element('article', '', 'event-card');card.id='event-card-'+e.id;card.tabIndex=-1;
if(e.kind==='photos'){
card.append(element('span','PHOTO SHARING','tag'),element('h3',e.title),action('Open collection',()=>openGallery(e.id)));
appendPhotoLinks(card,e);list.append(card);continue;
}
if (e.hasBanner) {
const img = document.createElement('img'); img.className = 'event-banner'; img.alt = `Banner for ${e.title}`; card.append(img);
fetch(`/api/events/${e.id}/banner`, { headers: { Authorization: 'tma ' + initData } }).then(r => { if (!r.ok) throw new Error(); return r.blob(); }).then(blob => { if (!img.isConnected) return; const old = bannerUrls.get(e.id); if (old) URL.revokeObjectURL(old); const url = URL.createObjectURL(blob); bannerUrls.set(e.id,url); img.src=url; }).catch(() => img.remove());
Expand Down Expand Up @@ -619,6 +663,8 @@ async function loadBannerPreview(id,generation){
}catch(error){if(generation===bannerLoadGeneration)window.reportAppError?.(error,'Banner preview');}
}
function setupForm(event = null) {
$('photo-create-panel').hidden=compactPicker || !!event;
$('photo-create-panel').open=false;
if(formSaving){notice('Your event is saving. Please wait.');return;}
formReady=true;
activeEvent = event; createdEvent = null; requestId = crypto.randomUUID();
Expand Down Expand Up @@ -1038,6 +1084,7 @@ $('profile-photo-form').onsubmit=async event=>{
};
$('profile-photo-remove').onclick=async()=>{const button=$('profile-photo-remove');button.disabled=true;try{const r=await fetch('/api/profile/photo',{method:'DELETE',headers:{Authorization:'tma '+initData}});if(!r.ok)throw Error('Could not remove photo.');state.preference.hasPhoto=false;button.hidden=true;await loadProfilePhoto();$('profile-photo-status').textContent='Photo removed.';}catch(error){$('profile-photo-status').textContent=error.message;}finally{button.disabled=false;}};
$('another-event').onclick = () => go('events');
setupPhotoCreation();
if (!initData) {
notice('This launch did not include your Telegram login. Reopen using App in the bot menu or the App button in a message.');
const empty=element('div','Your events are private. Use the authenticated App button to load them.','empty');
Expand Down
4 changes: 2 additions & 2 deletions public/gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ export function setupGallery({ $, api, element, action, go, notice, openTelegram
const kind=file.previewKind || (['photo','video'].includes(file.type)?file.type:null),previewFile={...file,type:kind};
const card=element('article','','gallery-item');const body=element('div','','gallery-item-body');
if(kind==='photo' || (kind==='video' && file.hasThumbnail)) {
const img=document.createElement('img');img.alt=file.caption || `${kind==='video'?'Video preview':'Photo'} shared by ${file.name}`;img.loading='lazy';card.append(img);
const img=document.createElement('img');img.alt=file.caption || `${kind==='video'?'Video preview':'Photo'}${file.name?' shared by '+file.name:''}`;img.loading='lazy';card.append(img);
const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id,file.hasThumbnail);if(!img.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);img.src=url;img.onclick=async()=>{const pending=++popupGeneration;try{const full=file.hasThumbnail?await mediaBlob(id,file.id):blob;if(!img.isConnected || current!==generation || pending!==popupGeneration)return;const fullUrl=file.hasThumbnail?URL.createObjectURL(full):url;urls.add(fullUrl);showMedia(id,previewFile,full,fullUrl);}catch(error){if(current===generation && pending===popupGeneration)notice(error.message);}};}catch(error){if(current!==generation)return;img.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(img);
}else if(kind==='video'){
const video=document.createElement('video');video.muted=true;video.playsInline=true;video.preload='metadata';video.setAttribute('aria-label',file.caption || 'Video preview');card.append(video);
const observer=new IntersectionObserver(async entries=>{if(!entries.some(entry=>entry.isIntersecting))return;observer.disconnect();try{const blob=await mediaBlob(id,file.id);if(!video.isConnected || current!==generation)return;const url=URL.createObjectURL(blob);urls.add(url);video.src=url;video.onloadeddata=()=>{try{video.currentTime=.1;}catch{}};video.onclick=()=>{if(current!==generation)return;++popupGeneration;showMedia(id,previewFile,blob,url);};}catch(error){if(current!==generation)return;video.remove();body.prepend(element('p',error.message,'small muted'));}});observer.observe(video);
}
body.append(element('strong',file.filename==='photo'?'Photo':file.filename),element('p',`Shared by ${file.name}${file.caption?'\n'+file.caption:''}`,'small muted'));
body.append(element('strong',file.filename==='photo'?'Photo':file.filename),element('p',[file.name?'Shared by '+file.name:'',file.caption].filter(Boolean).join('\n'),'small muted'));
const buttons=element('div','','event-actions');
buttons.append(action('Download',async()=>{try{saveBlob(await mediaBlob(id,file.id),file.filename==='photo'?'photo.jpg':file.filename==='video'?'video.mp4':file.filename);}catch(error){notice(error.message);}}));
buttons.append(action('Send to Telegram',async()=>{try{await api(`events/${id}/media/${file.id}/send`,{});notice('File sent to your Telegram chat.');}catch(error){notice(error.message);}}));
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<div id="event-list" aria-live="polite"><div class="empty">Loading your plans…</div></div>
</section>
<section id="create-view" class="view" hidden>
<details class="panel" id="photo-create-panel"><summary>📷 Photo sharing only</summary><p>Create a collection without an event. Share a viewer link to browse, or a contributor link to browse and add photos, videos and files.</p><form id="photo-form"><label for="photo-title">Collection name</label><input id="photo-title" maxlength="100" required placeholder="Our shared memories"><label class="check-option"><input type="checkbox" id="photo-show-uploaders" checked><span>Show who uploaded media<span class="check-note">Show uploader names in the gallery. Turn off to hide names; captions and file contents stay visible.</span></span></label><button type="submit" id="photo-create">Create photo-sharing links</button><p id="photo-error" role="alert" hidden></p></form><div id="photo-links" hidden></div></details>
<span class="eyebrow">BRING EVERYONE TOGETHER</span><h1 id="form-title">Make a plan.</h1><p class="muted" id="form-description">Pick a date. Share an invite. Let the good times follow.</p>
<p id="payment-owner-note" class="notice" hidden>You’re editing as co-host. Payment settings stay with the event owner.</p>
<div id="edit-checkin-panel" class="edit-checkin-panel" hidden><span class="small muted">At the door</span><button type="button" id="edit-checkin" class="secondary">🎟 Check in guests</button></div>
Expand Down
39 changes: 39 additions & 0 deletions scripts/check-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,45 @@ try {
assert.equal((await api(fixedPath,null,flexibleGuestId)).data.event.participants,3);
assert.equal((await api(fixedPath,null,fixedOwner)).data.event.counts.participants,5);
await db.prepare('DELETE FROM delivery_lease WHERE owner=?').bind(invitationDeliveryOwner).run();
// Photo-only collections use the same durable records and media transport, with separate role grants.
const photoOwner=880101,photoViewer=880102,photoContributor=880103;
const photoInput={title:'Fictional shared holiday',showUploaders:false,requestId:'ff880101-aaaa-aaaa-aaaa-aaaaaaaaaaaa'};
const photoMade=await api('collections',photoInput,photoOwner);
assert.equal(photoMade.status,200);assert.equal(photoMade.data.event.kind,'photos');
const photoId=photoMade.data.event.id;
assert.equal((await api('collections',photoInput,photoOwner)).data.event.id,photoId);
const photoRecord=async()=>JSON.parse((await db.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(photoId).first()).data);
const photos=await photoRecord();
assert.equal(photos.showUploaders,false);
const photoSettingsPath=`collections/${photoId}/settings`;
assert.equal((await api(photoSettingsPath,{showUploaders:true},photoViewer)).status,400);
assert.equal((await api(photoSettingsPath,{showUploaders:'false'},photoOwner)).status,400);
assert.deepEqual(photos.guests,{});assert.equal(photos.startsAt,undefined);assert.equal(photos.isPublic,false);
assert.equal((await api(`events/${photoId}/gallery`,null,photoViewer)).status,403);
await message(880101,'/start p_'+photos.viewToken,photoViewer);
let photoGallery=await api(`events/${photoId}/gallery`,null,photoViewer);
assert.equal(photoGallery.status,200);assert.equal(photoGallery.data.canUpload,false);
assert.equal(photoGallery.data.galleryUrl,photoMade.data.event.photoLinks.viewer);
assert.ok(!JSON.stringify(photoGallery.data).includes(photos.uploadToken));
await callback(880102,`media-add:${photoId}`,photoViewer);
await message(880103,'',photoViewer,{photo:[{file_id:'fictional-denied-upload',file_size:100}]});
assert.equal((await photoRecord()).media.length,0);
await message(880104,'/start u_'+photos.uploadToken,photoContributor);
assert.equal((await api(`events/${photoId}/gallery`,null,photoContributor)).data.canUpload,true);
await callback(880105,`media-add:${photoId}`,photoContributor);
await message(880106,'',photoContributor,{photo:[{file_id:'fictional-collection-photo',file_size:100}]});
assert.equal((await photoRecord()).media.length,1);assert.deepEqual((await photoRecord()).guests,{});
assert.equal((await api(`events/${photoId}/gallery`,null,photoViewer)).data.media.length,1);
assert.equal((await api(`events/${photoId}/gallery`,null,photoViewer)).data.media[0].name,null);
assert.equal((await api(photoSettingsPath,{showUploaders:true},photoOwner)).status,200);
assert.equal((await api(`events/${photoId}/gallery`,null,photoViewer)).data.media[0].name,'Tester');
await callback(880107,`photo-names:${photoId}`,photoContributor);assert.equal((await photoRecord()).showUploaders,true);
await callback(880108,`photo-names:${photoId}`,photoOwner);assert.equal((await photoRecord()).showUploaders,false);
assert.equal((await photoRecord()).media[0].name,'Tester');
const privatePhotos=(await api('bootstrap',null,photoOwner)).data.events.find(e=>e.id===photoId);
assert.ok(privatePhotos.photoLinks.viewer);assert.ok(privatePhotos.photoLinks.contributor);
assert.equal((await api(`events/${photoId}/delete`,{confirm:true},photoOwner)).status,200);
assert.equal((await api(`events/${photoId}/gallery`,null,photoViewer)).status,403);
console.log('Worker integration passed: compact invitation tools, private history and archives, edits, response changes, revocation/deletion and idempotent notifications; fixed personal attendee counts and live count button captions, invitation response summaries, add/revoke with notify or silent removal, exact durable notifications, native button event creation, profiles, private photos, ticket bookings, private expiring check-in codes and legacy QR compatibility, personal RSVP links, multiple tagged co-host links and isolated revocation, legacy co-host migration, approvals, payments, durable refunds and media. Telegram mocked.');
} finally { await mf.dispose(); }
await checkTestAccess();
Expand Down
Loading
Loading