diff --git a/docs/features.md b/docs/features.md
index 9437cfc..633e75a 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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.
diff --git a/public/app.js b/public/app.js
index c817a89..0f25dda 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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) {
@@ -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);}
@@ -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());
@@ -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();
@@ -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');
diff --git a/public/gallery.js b/public/gallery.js
index 4a8d300..3accb32 100644
--- a/public/gallery.js
+++ b/public/gallery.js
@@ -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);}}));
diff --git a/public/index.html b/public/index.html
index c4aab93..ca1eb1b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -27,6 +27,7 @@
Loading your plansβ¦
+ π· Photo sharing only
Create a collection without an event. Share a viewer link to browse, or a contributor link to browse and add photos, videos and files.
BRING EVERYONE TOGETHER
Make a plan.
Pick a date. Share an invite. Let the good times follow.
Youβre editing as co-host. Payment settings stay with the event owner.
At the door
diff --git a/scripts/check-worker.mjs b/scripts/check-worker.mjs
index 9f41ac0..8562a58 100644
--- a/scripts/check-worker.mjs
+++ b/scripts/check-worker.mjs
@@ -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();
diff --git a/src/bot.js b/src/bot.js
index 4e4800d..08748f5 100644
--- a/src/bot.js
+++ b/src/bot.js
@@ -12,7 +12,7 @@ import { issueTicket } from './tickets.js';
import { randomBytes } from 'node:crypto';
import { invoice, checkout, successful, orderFor, requestRefund, refundResult } from './payments.js';
import { eventTime, InputError } from './time.js';
-import { uploadLink, shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
+import { photoCollection,photoLinks,showsUploaders,mediaRole,mediaCan,uploadLink, shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
import { upcoming, eventGroup, setReminder, reminderOptions, reminderLabel, applyDefaultReminder } from './reminders.js';
import { permissions, permissionLabels, can, guests, confirmed, canSeeLocation, responsesClosed, responseCounts, participantCount } from './permissions.js';
@@ -27,7 +27,7 @@ const hasRecordedResponse = guest => typeof guest?.responseRecorded==='boolean'
const menu = { new: 'π Create event', events: 'π My events', help: 'β Help', home: 'π Main menu', cancel: 'βοΈ Cancel input', skip: 'β Skip', done: 'β Finish uploads', name: 'π€ Use Telegram name', app: 'App', picker: 'π Pick date & time', pending: 'β³ Pending invitations' };
const reply = (...rows) => ({ keyboard: rows.map(row => row.map(text => typeof text === 'string' ? { text } : text)), resize_keyboard: true, is_persistent: true });
// The authenticated App launcher lives in Telegram's built-in chat menu.
-const homeKeyboard = () => reply([menu.new, menu.events], [menu.help]);
+const homeKeyboard = () => reply([menu.new, menu.events], ['π· Photo sharing',menu.help]);
export class Bot {
constructor(store, api, username, appUrl) { this.store = store; this.api = api; this.username = username; this.appUrl = appUrl; this.store.data.preferences ||= {}; }
@@ -99,12 +99,19 @@ export class Bot {
return 'Last updated: ' + formatted;
}
allowed(e, id) { return e && (isManager(e,id) || !!e.guests[id] && invitationAvailable(e,id)); }
- mediaAllowed(e, id) { return e && (this.allowed(e, id) || (this.db.preferences[id]?.mediaAccess?.[e.id] === e.uploadToken && !!uploadLink(e, this.username))); }
+ mediaAllowed(e, id) { return e && (e.kind==='photos' ? !!mediaRole(e,id,this.db.preferences[id]) : this.allowed(e, id) || !!mediaRole(e,id,this.db.preferences[id])); }
+ mediaCan(e,id,key) { return mediaCan(e,id,key,this.db.preferences[id]); }
async mediaCard(id, e) {
if (!this.mediaAllowed(e, id) || e.cancelled) return this.home(id, 'This media link is unavailable.');
const rows=[];
- if (can(e,id,'uploadMedia')) rows.push([button('π Add media',`media-add:${e.id}`)]);
- if (this.appUrl && can(e,id,'viewMedia')) rows.push([this.miniButton('π Shared media',`?gallery=${e.id}`)]);
+ if (this.mediaCan(e,id,'uploadMedia')) rows.push([button('π Add media',`media-add:${e.id}`)]);
+ if (this.appUrl && this.mediaCan(e,id,'viewMedia')) rows.push([this.miniButton('π Shared media',`?gallery=${e.id}`)]);
+ if (!this.appUrl && this.mediaCan(e,id,'viewMedia')) rows.push([button('π Shared media',`m:${e.id}:0`)]);
+ if(e.kind==='photos' && e.owner===id){
+ const links=photoLinks(e,this.username);
+ rows.push([{text:'Share viewer link',url:`https://t.me/share/url?url=${encodeURIComponent(links.viewer)}`}],[{text:'Share contributor link',url:`https://t.me/share/url?url=${encodeURIComponent(links.contributor)}`}]);
+ rows.push([button(showsUploaders(e)?'Uploader names: shown Β· Hide':'Uploader names: hidden Β· Show',`photo-names:${e.id}`)]);
+ }
rows.push([button(menu.home,'nav:home')]);
if (e.banner) return this.api('sendPhoto',{chat_id:id,photo:e.banner,caption:e.title,reply_markup:keyboard(...rows)});
return this.send(id,e.title,keyboard(...rows));
@@ -241,6 +248,7 @@ export class Bot {
this.db.events[e.id] = e; this.session(id); await this.home(id, invitationMode(e)==='named'?'π Event ready! Open Personal invitations to share guest links.':'π Event ready! Tap Share ticket link to invite people.'); return this.card(id, e);
}
async card(id, e, withBanner = true) {
+ if(e.kind==='photos')return this.mediaCard(id,e);
const owner=e.owner===id,host = isManager(e,id);
const counted = responseCounts(e);
let counts = can(e, id, 'guestList') ? [`People coming: ${counted.participants}`, ...(counted.pending ? [`People awaiting approval: ${counted.pendingParticipants}`] : []), ...Object.keys(labels).map(s => `${labels[s]}: ${counted[s]}`), ...(counted.pending ? [`β³ Awaiting approval: ${counted.pending}`] : []), ...(counted.awaitingPayment ? [`β Awaiting payment: ${counted.awaitingPayment}`] : [])].join('\n') : `Your response: ${e.guests[id]?.status === 'yes' && !confirmed(e, e.guests[id]) ? paidEvent(e) && (!requiresApproval(e) || e.guests[id]?.approval === 'approved') ? 'Awaiting payment' : 'Awaiting organiser approval' : labels[e.guests[id]?.status] || 'Not submitted'}`;
@@ -331,6 +339,7 @@ export class Bot {
if(text==='Done' || text==='π¨ Send message')return sendBroadcast(this,id,event);
return collectBroadcast(this,id,event,m);
}
+ if(text==='π· Photo sharing'){this.session(id,{step:'photo-title'});return this.prompt(id,'Photo collection name? (up to 100 characters)');}
if(text===menu.new)return startChatCreation(this,id);
const navigation = { [menu.new]: '/new', [menu.events]: '/events', [menu.help]: '/help', [menu.home]: '/start', [menu.cancel]: '/cancel', [menu.app]: '/app', [menu.picker]: '/picker', [menu.pending]: '/pending' };
if (['π± Open app','π± Open planner','π± Open XEvents planner'].includes(text)) text = '/app';
@@ -365,10 +374,18 @@ export class Bot {
if (command === '/start') {
if (this.appUrl) await this.api('setChatMenuButton', { chat_id: id, menu_button: { type: 'web_app', text: 'App', web_app: { url: this.appUrl } } });
this.session(id);
+ const photoMatch=text.match(/^\/start(?:@\w+)? p_([a-f0-9]{32})$/);
+ if(photoMatch){
+ const e=Object.values(this.db.events).find(e=>e.kind==='photos' && !e.cancelled && e.viewToken===photoMatch[1]);
+ if(!e)return this.home(id,'This photo-sharing link is unavailable.');
+ this.db.preferences[id] ||= {};this.db.preferences[id].mediaAccess ||= {};
+ this.db.preferences[id].mediaAccess[e.id]=photoMatch[1];
+ return this.mediaCard(id,e);
+ }
const uploadMatch = text.match(/^\/start(?:@\w+)? (u_([a-f0-9]{32})|a_([a-f0-9]{16}))$/);
if (uploadMatch) {
const e = uploadMatch[2] ? Object.values(this.db.events).find(e => e.uploadToken === uploadMatch[2] && uploadLink(e, this.username)) : this.db.events[uploadMatch[3]];
- if (!e || e.cancelled || (!uploadMatch[2] && (!this.mediaAllowed(e, id) || !can(e, id, 'uploadMedia')))) return this.home(id, 'This upload link is unavailable.');
+ if (!e || e.cancelled || (!uploadMatch[2] && (!this.mediaAllowed(e, id) || !this.mediaCan(e, id, 'uploadMedia')))) return this.home(id, 'This upload link is unavailable.');
if (uploadMatch[2]) { this.db.preferences[id] ||= {}; this.db.preferences[id].mediaAccess ||= {}; this.db.preferences[id].mediaAccess[e.id] = uploadMatch[2]; }
this.session(id, { step: 'media', event: e.id });
return this.mediaCard(id,e);
@@ -390,6 +407,7 @@ export class Bot {
if (match) {
const e = this.db.events[match[1]];
if (!e) return this.send(id, 'This invitation is unavailable. Ask the organiser for a new link.');
+ if(e.kind==='photos')return this.mediaCard(id,e);
if(invitationMode(e)==='named' && !isManager(e,id) && !e.guests[id])return this.home(id,'Ask the organiser for your personal invitation link.');
if(!isManager(e,id) && !invitationAvailable(e,id))return this.home(id,'This invitation has already been used by another guest. Ask the organiser for your own link.');
if (!e.cancelled && !isManager(e,id) && !e.guests[id]) e.guests[id] = { name: name(m.from), status: 'later', comment: '', answers: [], phone: '' };
@@ -418,6 +436,11 @@ export class Bot {
if (text.startsWith('/') && command !== '/skip' && command !== '/done') return this.send(id, 'Choose a menu button, or tap Cancel input to leave this step.', current ? this.inputKeyboard(current) : homeKeyboard());
const s = this.db.sessions[id];
if (!s) return this.home(id, 'Choose Create event or My events below.');
+ if(s.step==='photo-title'){
+ if(!text || text.length>100)return this.prompt(id,'Use a collection name of 1β100 characters.');
+ const collection=photoCollection(text,id);this.db.events[collection.id]=collection;this.session(id);
+ await this.home(id,'Photo collection ready. Share a viewer or contributor link.');return this.mediaCard(id,collection);
+ }
if(s.flow==='chat-create')return chatCreationMessage(this,id,typeof m.text==='string'?m.text.trim():text,s,m);
if (s.step === 'banner') {
const target = s.draft || this.db.events[s.event];
@@ -437,7 +460,7 @@ export class Bot {
this.session(id);await this.home(id,'Co-host link created.');return this.cohostEntryCard(id,e,entry.id);
}
const linkUploader = s.step === 'upload' && e && s.uploadToken && s.uploadToken === e.uploadToken && !!uploadLink(e, this.username);
- if ((!this.allowed(e, id) && !linkUploader) || e.cancelled) { this.session(id); return this.send(id, 'This event is no longer available for changes.'); }
+ if ((!this.allowed(e, id) && !linkUploader) || e.cancelled || e.kind==='photos' && (s.step!=='upload' || !this.mediaCan(e,id,'uploadMedia'))) { this.session(id); return this.send(id, 'This event is no longer available for changes.'); }
const g = e.guests[id];
if (s.response && isManager(e,id)) { this.session(id); return this.card(id, e); }
if (s.response && responsesClosed(e)) { this.session(id); await this.home(id, 'The response deadline has passed.'); return this.card(id, e); }
@@ -579,14 +602,23 @@ export class Bot {
return this.creationPermissions(id, s);
}
if(action==='nav' && eid==='new')return startChatCreation(this,id);
+ if(action==='photos-new'){this.session(id,{step:'photo-title'});return this.prompt(id,'Photo collection name? (up to 100 characters)');}
+ if(action==='photo-names'){
+ if(e?.kind!=='photos' || e.owner!==id || e.cancelled)return this.home(id,'Only the collection owner can change this setting.');
+ e.showUploaders=!showsUploaders(e);return this.mediaCard(id,e);
+ }
if (action === 'nav' && ['home', 'events', 'pending'].includes(eid)) return this.handle({ message: { from: q.from, chat: { id, type: 'private' }, text: { home: '/start', events: '/events', pending: '/pending' }[eid] } });
if (action === 'media-add') {
- if (!this.mediaAllowed(e,id) || e.cancelled || !can(e,id,'uploadMedia')) return this.home(id,'The organiser has disabled these uploads.');
+ if (!this.mediaAllowed(e,id) || e.cancelled || !this.mediaCan(e,id,'uploadMedia')) return this.home(id,'The organiser has disabled these uploads.');
const token=this.db.preferences[id]?.mediaAccess?.[e.id];
this.session(id,{step:'upload',event:e.id,mediaOnly:true,uploads:0,uploadToken:token===e.uploadToken ? token : null});
return this.prompt(id,'Send photos, videos, or files, then tap Finish uploads.');
}
- if (!this.allowed(e, id)) return this.send(id, 'Open a valid invitation link first.');
+ if(e?.kind==='photos'){
+ if(!['m','v','remove'].includes(action) || !this.mediaAllowed(e,id) || action==='remove' && e.owner!==id)return this.home(id,'Use the photo collection buttons.');
+ if(action==='v')return this.mediaCard(id,e);
+ }
+ if (!this.allowed(e, id) && !(e?.kind==='photos' && action==='m' && this.mediaCan(e,id,'viewMedia'))) return this.send(id, 'Open a valid invitation link first.');
if (action === 'star-terms') return this.paymentInfo(id,e);
if(action==='manual-report')return manualReport(this,id,e);
if(action==='manual-confirm' || action==='manual-clear' || action==='manual-clear-confirm'){
@@ -738,7 +770,7 @@ export class Bot {
if (!media.length) return this.send(id, 'No shared media on this page yet.', keyboard(...(can(e, id, 'uploadMedia') ? [[button('π Add media', `u:${eid}`)]] : []), [button('Back to event', `v:${eid}`)]));
for (const f of media) {
const method = { photo: 'sendPhoto', video: 'sendVideo', document: 'sendDocument' }[f.type];
- await this.api(method, { chat_id: id, [f.type]: f.fileId, caption: `Shared by ${f.name}${f.caption ? '\n' + f.caption : ''}`, ...(isManager(e,id) ? { reply_markup: keyboard([button('Remove from event', `remove:${eid}:${f.id}`)]) } : {}) });
+ await this.api(method, { chat_id: id, [f.type]: f.fileId, caption: [showsUploaders(e)?`Shared by ${f.name}`:'Shared media',f.caption].filter(Boolean).join('\n'), ...(isManager(e,id) ? { reply_markup: keyboard([button('Remove from event', `remove:${eid}:${f.id}`)]) } : {}) });
}
const nav = [];
if (page > 0) nav.push(button('β Previous', `m:${eid}:${page - 1}`));
diff --git a/src/chat-creation.js b/src/chat-creation.js
index 586e4fc..61332c7 100644
--- a/src/chat-creation.js
+++ b/src/chat-creation.js
@@ -62,11 +62,13 @@ function reminders(bot,id,s){return choices(bot,id,s,'chat-reminders','Default r
function durationCard(bot,id,s){return choices(bot,id,s,'chat-duration','How long is the event?',[[['1 hour','duration-set',60],['2 hours','duration-set',120]],[['3 hours','duration-set',180],['4 hours','duration-set',240]],[['No finish time','duration-set',0],['Other duration','custom-duration']],[['Back','back'],['Cancel','cancel']]]);}
function deadlineCard(bot,id,s){return choices(bot,id,s,'chat-deadline',s.draft.responseDeadline?'Respond by: '+eventTime({startsAt:s.draft.responseDeadline,timezone:s.draft.deadlineTimezone || s.draft.timezone}):'No response deadline.',[[['Set deadline','deadline-set'],['No deadline','deadline-clear']],[['Back','back'],['Cancel','cancel']]]);}
-export function startChatCreation(bot,id){
+export async function startChatCreation(bot,id){
const s={flow:'chat-create',step:'chat-title',token:randomBytes(6).toString('hex'),draft:{invitationMode:'tickets',oneTimeInvite:false,isPublic:false,requireApproval:false,askParticipantCount:false,askPhone:false,askComments:false,qrEnabled:false,permissions:{guestList:false,uploadMedia:false,viewMedia:false},description:'',inviteMessage:'',questions:[],location:'',endMode:'none',defaultReminder:0}};
bot.session(id,s);
+ await photoChoice(bot,id);
return bot.send(id,'Event name?',{keyboard:[[{text:'βοΈ Cancel input'}]],resize_keyboard:true});
}
+function photoChoice(bot,id){return bot.send(id,'Or create only a photo-sharing link β no event details needed.',{inline_keyboard:[[{text:'π· Photo sharing only',callback_data:'photos-new'}]]});}
function back(bot,id,s){
if(s.step==='chat-title')return s.draft.title?startPick(bot,id,s):bot.send(id,'Event name? Or tap Cancel input.');
if(['chat-date','chat-custom-date'].includes(s.step))return s.picking==='deadline' || s.editingSchedule?options(bot,id,s):ask(bot,id,s,'chat-title','Event name?');
diff --git a/src/media-api.js b/src/media-api.js
index ea64123..ab999bc 100644
--- a/src/media-api.js
+++ b/src/media-api.js
@@ -1,6 +1,6 @@
import qrcode from 'qrcode-generator';
import {mediaPreview,previewMime} from './media-preview.js';
-import { can, shareUploadLink, uploadLink } from './permissions.js';
+import {mediaCan,mediaRole,photoLinks,showsUploaders,shareUploadLink} from './permissions.js';
import { mutateState } from './worker-store.js';
import {isManager} from './cohosts.js';
import {invitationAvailable} from './invitations.js';
@@ -15,7 +15,8 @@ export async function mediaApi(request, env, user) {
const row = await env.DB.prepare("SELECT data FROM records WHERE kind='events' AND id=?").bind(match[1]).first();
const e = row && JSON.parse(row.data);
const preference = await env.DB.prepare("SELECT data FROM records WHERE kind='preferences' AND id=?").bind(String(user.id)).first();
- const grant = e && preference && JSON.parse(preference.data).mediaAccess?.[e.id] === e.uploadToken && !!uploadLink(e,env.BOT_USERNAME);
+ const pref=preference ? JSON.parse(preference.data) : {};
+ const grant = !!mediaRole(e,user.id,pref);
if (!e || (!isManager(e,user.id) && !(e.guests[user.id] && invitationAvailable(e,user.id)) && !grant)) return json({ error: 'Open a valid event or media link first.' }, 403);
if (match[2] === 'upload-qr') {
if (request.method !== 'GET' || !isManager(e,user.id)) return json({ error: 'Only an organiser can view the upload QR code.' }, 403);
@@ -25,20 +26,20 @@ export async function mediaApi(request, env, user) {
const qr = qrcode(0, 'M'); qr.addData(link); qr.make();
return json({ link, anyone: !!e.allowLinkUploads, image: qr.createDataURL(6, 24) });
}
- if (!can(e, user.id, 'viewMedia')) return json({ error: 'The organiser has not enabled the gallery for guests.' }, 403);
- const galleryUrl=`https://t.me/${env.BOT_USERNAME}?startapp=gallery_${e.id}`;
+ if (!mediaCan(e, user.id, 'viewMedia',pref)) return json({ error: 'The organiser has not enabled the gallery for guests.' }, 403);
+ const galleryUrl=e.kind==='photos' ? photoLinks(e,env.BOT_USERNAME)?.viewer : `https://t.me/${env.BOT_USERNAME}?startapp=gallery_${e.id}`;
if(match[2]==='gallery-qr' && request.method==='GET'){
if(e.qrEnabled===false)return json({error:'QR codes are disabled for this event.'},400);
const qr=qrcode(0,'M');qr.addData(galleryUrl);qr.make();
return json({link:galleryUrl,image:qr.createDataURL(6,24)});
}
- if (match[2] === 'gallery' && request.method === 'GET') return json({ id: e.id, title: e.title, galleryUrl, qrEnabled:e.qrEnabled!==false, cancelled: !!e.cancelled, canUpload: !e.cancelled && can(e, user.id, 'uploadMedia'), uploadUrl: `https://t.me/${env.BOT_USERNAME}?start=a_${e.id}`, media: (e.media || []).map(f => ({ id: f.id, type: f.type, previewKind:mediaPreview(f),hasThumbnail:!!f.thumbnail,filename: f.filename, caption: f.caption, name: f.name, at: f.at, size: f.size || null })) });
+ if (match[2] === 'gallery' && request.method === 'GET') return json({ id: e.id, title: e.title, galleryUrl, qrEnabled:e.qrEnabled!==false, cancelled: !!e.cancelled, canUpload: !e.cancelled && mediaCan(e, user.id, 'uploadMedia',pref), uploadUrl: `https://t.me/${env.BOT_USERNAME}?start=a_${e.id}`, media: (e.media || []).map(f => ({ id: f.id, type: f.type, previewKind:mediaPreview(f),hasThumbnail:!!f.thumbnail,filename: f.filename, caption: f.caption, name: showsUploaders(e)?f.name:null, at: f.at, size: f.size || null })) });
const f = e.media?.find(item => item.id === match[3]);
if (!f) return json({ error: 'This file is no longer in the event.' }, 404);
if (match[4] === 'send' && request.method === 'POST') {
await mutateState(env, async (data, bot) => {
const current = data.events[e.id];
- if (!bot.mediaAllowed(current, user.id) || !can(current, user.id, 'viewMedia')) throw new Error('Access changed');
+ if (!bot.mediaAllowed(current, user.id) || !bot.mediaCan(current, user.id, 'viewMedia')) throw new Error('Access changed');
const file = current.media.find(item => item.id === f.id); if (!file) throw new Error('File removed');
await bot.api({ photo: 'sendPhoto', video: 'sendVideo', document: 'sendDocument' }[file.type], { chat_id: user.id, [file.type]: file.fileId, caption: file.caption || undefined });
});
diff --git a/src/mini-api.js b/src/mini-api.js
index d16a311..15755b6 100644
--- a/src/mini-api.js
+++ b/src/mini-api.js
@@ -11,7 +11,7 @@ import { authenticate } from './mini-auth.js';
import { mayUseTestApp, testAccessMessage, TestAccessError, whitelistStatus, applyWhitelistChange } from './test-access.js';
import { parsePricing, currencyCodes, localCurrency } from './pricing.js';
import { mediaApi } from './media-api.js';
-import { shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
+import { photoCollection,photoLinks,shareUploadLink,asksPhone,asksComments,requiresApproval,asksParticipantCount,hidesLocation } from './permissions.js';
import { isSuperAdmin, rememberUser, adminOverview, adminAnalytics, adminUserCount } from './admin.js';
import { schedule, timezone, InputError } from './time.js';
import { mutateState, BusyError } from './worker-store.js';
@@ -86,6 +86,7 @@ export function publicEvent(e, id, username) {
const manager=isManager(e,id),owner=e.owner===id;
const entries=cohostEntries(e),cohosts=entries.filter(entry=>entry.status==='active' && entry.cohost).map(entry=>entry.cohost);
return {
+ kind:e.kind || 'event',...(e.kind==='photos' ? {showUploaders:e.showUploaders!==false} : {}),...(owner && e.kind==='photos' ? {photoLinks:photoLinks(e,username)} : {}),
invitationMode:invitationMode(e),guestName:e.owner!==id ? e.guests[id]?.name || null : null,
askPhone:asksPhone(e),askComments:asksComments(e),
...(manager ? {guestRoster:[...Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner).map(([uid,g])=>({id:Number(uid),name:g.name,status:g.status,responded:hasRecordedResponse(g,invitationHistory(e,g.invitationToken),Number(uid)),approval:g.status==='yes' ? requiresApproval(e) ? g.approval || 'pending' : 'approved' : null,confirmed:confirmed(e,g),participants:participantCount(e,g),paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([token,g])=>({id:null,name:g.name,status:'unopened',responded:false,participants:participantCount(e,{...g,invitationToken:token}),confirmed:false}))]} : {}),
@@ -105,7 +106,7 @@ export function publicEvent(e, id, username) {
mediaCount: can(e, id, 'viewMedia') ? e.media?.length || 0 : null,
imageCount: can(e, id, 'viewMedia') ? e.media?.filter(f => f.type === 'photo').length || 0 : null,
...(manager ? { allowLinkUploads: !!e.allowLinkUploads, uploadLink: shareUploadLink(e, username) } : {}),
- group: eventGroup(e), upcoming: upcoming(e), reminder: e.reminders?.[id]?.minutes || 0, hasBanner: !!e.banner,
+ group: e.kind==='photos'?'Photo collections':eventGroup(e), upcoming: upcoming(e), reminder: e.reminders?.[id]?.minutes || 0, hasBanner: !!e.banner,
askParticipantCount: asksParticipantCount(e), participants: e.guests[id]?.status === 'yes' ? participantCount(e, e.guests[id]) : null, requireApproval: requiresApproval(e), hideLocation: hidesLocation(e),
responseDeadline: e.responseDeadline || null, responsesClosed: responsesClosed(e), deadlineDate: e.deadlineDate || '', deadlineTime: e.deadlineTime || '', deadlineTimezone: e.deadlineTimezone || e.timezone || null,
...(manager ? { ticketInfo: e.ticketInfo || '' } : {}),
@@ -303,10 +304,25 @@ async function miniApiForUser(request, env, user) {
Object.assign(data.preferences[id],profileFields(input));
return { preference: profilePreference(data.preferences[id]) };
}
- if (path === '/api/events') {
+ const photoSettings=path.match(/^\/api\/collections\/([a-f0-9]{16})\/settings$/);
+ if(photoSettings){
+ const e=data.events[photoSettings[1]];
+ if(e?.kind!=='photos' || e.owner!==id || e.cancelled)throw new InputError('Only the collection owner can change this setting.');
+ if(typeof input.showUploaders!=='boolean')throw new InputError('Choose whether to show uploader names.');
+ e.showUploaders=input.showUploaders;
+ return {event:publicEvent(e,id,env.BOT_USERNAME)};
+ }
+ if (path === '/api/events' || path === '/api/collections') {
if (!/^[a-f0-9-]{36}$/.test(input.requestId || '')) throw new InputError('Refresh the planner and try again.');
const existing = Object.values(data.events).find(e => e.owner === id && e.createRequestId === input.requestId);
if (existing) return { event: publicEvent(existing, id, env.BOT_USERNAME) };
+ if(path==='/api/collections') {
+ if(input.showUploaders!==undefined && typeof input.showUploaders!=='boolean')throw new InputError('Choose whether to show uploader names.');
+ const e=photoCollection(field(input.title,'Collection name',100,true),id,input.showUploaders ?? true);
+ e.createRequestId=input.requestId;data.events[e.id]=e;
+ await bot.card(id,e);
+ return {event:publicEvent(e,id,env.BOT_USERNAME)};
+ }
const title = field(input.title, 'Event name', 100, true);
const location = field(input.location ?? '', 'Location', 300);
const description = field(input.description ?? '', 'Description', 1500);
diff --git a/src/permissions.js b/src/permissions.js
index b156d44..10f2219 100644
--- a/src/permissions.js
+++ b/src/permissions.js
@@ -1,4 +1,24 @@
import {isManager} from './cohosts.js';
+import {randomBytes} from 'node:crypto';
+
+export function photoCollection(title,owner,showUploaders=true) {
+ return {id:randomBytes(8).toString('hex'),kind:'photos',title,owner,showUploaders,invitationMode:'tickets',isPublic:false,guests:{},media:[],cancelled:false,createdAt:new Date().toISOString(),permissions:{guestList:false,viewMedia:true,uploadMedia:true},allowLinkUploads:true,uploadToken:randomBytes(16).toString('hex'),viewToken:randomBytes(16).toString('hex'),qrEnabled:true,location:'',description:'',questions:[],defaultReminder:0};
+}
+export const showsUploaders=e=>e.kind!=='photos' || e.showUploaders!==false;
+export function mediaRole(e,id,preference={}) {
+ if(!e || e.cancelled)return null;
+ if(isManager(e,id))return 'owner';
+ const token=preference.mediaAccess?.[e.id];
+ if(token && token===e.viewToken && e.kind==='photos')return 'viewer';
+ if(token && token===e.uploadToken && uploadLink(e,''))return 'contributor';
+ return null;
+}
+export function mediaCan(e,id,key,preference={}) {
+ const role=mediaRole(e,id,preference);
+ if(e?.kind==='photos')return ['viewMedia','uploadMedia'].includes(key) && (role==='owner' || role==='contributor' || role==='viewer' && key==='viewMedia');
+ return can(e,id,key);
+}
+export const photoLinks=(e,username)=>e.kind==='photos' && !e.cancelled ? {viewer:`https://t.me/${username}?start=p_${e.viewToken}`,contributor:uploadLink(e,username)} : null;
export const permissionLabels = { guestList: 'See other guests', uploadMedia: 'Send photos, videos & files', viewMedia: 'See shared media' };
export const asksPhone=e=>e.askPhone === true;
diff --git a/test/bot.test.js b/test/bot.test.js
index 359989b..f760ff9 100644
--- a/test/bot.test.js
+++ b/test/bot.test.js
@@ -149,7 +149,7 @@ test('native App menu stays available without duplicate App buttons or stale key
const f=fixture();f.bot.appUrl='https://example.test/app';
await f.msg(1,'/start');
const entries=f.calls.at(-1).reply_markup.keyboard.flat();
- assert.deepEqual(entries.map(item=>item.text),['π Create event','π My events','β Help']);
+ assert.deepEqual(entries.map(item=>item.text),['π Create event','π My events','π· Photo sharing','β Help']);
assert.equal(entries.some(item=>item.web_app || item.text==='App'),false);
assert.ok(f.calls.some(c=>c.method==='setChatMenuButton' && c.menu_button.text==='App' && c.menu_button.web_app.url===f.bot.appUrl));
for(const text of ['App','π± Open app','π± Open planner','/start app']){
diff --git a/test/cohost-ui.test.js b/test/cohost-ui.test.js
index 9f3a646..be728c3 100644
--- a/test/cohost-ui.test.js
+++ b/test/cohost-ui.test.js
@@ -74,6 +74,13 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit
event=project({...event,cohostLinks:links,cohostVersion:(++revision).toString(16).padStart(16,'0')});
return response({event});
}
+ if(path==='/api/collections'){
+ if(eventSaveHold){const hold=eventSaveHold;eventSaveHold=null;await hold;}
+ if(scheduleError)return response({error:scheduleError},400);
+ event={...event,kind:'photos',title:body.title,showUploaders:body.showUploaders,photoLinks:{viewer:'https://t.me/test?start=p_fictional',contributor:'https://t.me/test?start=u_fictional'}};
+ return response({event});
+ }
+ if(/^\/api\/collections\/[a-f0-9]{16}\/settings$/.test(path)){event={...event,showUploaders:body.showUploaders};return response({event});}
if(path==='/api/events'){if(eventSaveHold){const hold=eventSaveHold;eventSaveHold=null;await hold;}event={...event,...body};return response({event});}
if(path.endsWith('/schedule')){if(eventSaveHold){const hold=eventSaveHold;eventSaveHold=null;await hold;}if(scheduleError)return response({error:scheduleError},400);event={...event,...body};return response({event});}
if(path==='/api/preview')return response({startsAt:event.startsAt,timezone:event.timezone});
@@ -94,6 +101,36 @@ async function harness(initial,{scheduleError,clipboardMode='ok',search='',invit
return {ids,tabs,calls,errors,errorContexts,copied,telegramLinks,telegramStartupCalls,scanCallbacks,findButton,findEntry,descendants,setClipboardMode:value=>{clipboardMode=value;},setEvent:value=>{event=value;},getEvent:()=>event,setInvitationError:value=>{invitationError=value;},holdNextInvitationMutation:()=>{let release;invitationHold=new Promise(resolve=>{release=resolve;});return release;},holdNextEventRead:()=>{let release;eventReadHold=new Promise(resolve=>{release=resolve;});return release;},holdNextEventSave:()=>{let release;eventSaveHold=new Promise(resolve=>{release=resolve;});return release;},holdNextTicket:()=>{let release;ticketHold=new Promise(resolve=>{release=resolve;});return release;},holdNextTicketCheck:()=>{let release;ticketCheckHold=new Promise(resolve=>{release=resolve;});return release;},pendingTimers:()=>timers.size,advanceTime:milliseconds=>{clock.now+=milliseconds;for(const [id,timer] of [...timers])if(timer.at<=clock.now){timers.delete(id);timer.callback();}}};
}
+test('photo-only Mini App form hides event fields, creates both role links and shares each exact role',async()=>{
+ const f=await harness(eventFixture());f.ids.get('hero-create').onclick();
+ const panel=f.ids.get('photo-create-panel');panel.open=true;panel.ontoggle();
+ assert.equal(f.ids.get('event-form').hidden,true);
+ assert.match(f.ids.get('form-title').textContent,/memories/);
+ f.ids.get('photo-title').value='Fictional shared memories';
+ f.ids.get('photo-show-uploaders').checked=false;
+ await f.ids.get('photo-form').onsubmit({preventDefault(){}});
+ const call=f.calls.find(call=>call.path==='/api/collections');
+ assert.equal(call.body.title,'Fictional shared memories');assert.equal(call.body.showUploaders,false);assert.equal(call.body.date,undefined);assert.ok(call.body.requestId);
+ assert.equal(f.ids.get('photo-links').hidden,false);assert.equal(f.ids.get('photo-create').disabled,false);
+ for(const role of ['viewer','contributor']){
+ f.findButton('photo-links','Share '+role+' link').onclick();
+ assert.equal(new URL(f.telegramLinks.at(-1)).searchParams.get('url'),f.getEvent().photoLinks[role]);
+ }
+ const toggle=f.descendants(f.ids.get('photo-links')).find(node=>node.type==='checkbox');
+ assert.equal(toggle.checked,false);toggle.checked=true;await toggle.onchange();
+ assert.equal(f.calls.find(call=>call.path.endsWith('/settings')).body.showUploaders,true);
+ assert.equal(f.getEvent().showUploaders,true);
+ panel.open=false;panel.ontoggle();assert.equal(f.ids.get('event-form').hidden,false);
+});
+
+test('failed photo-only creation keeps a visible error and retries with the same request identity',async()=>{
+ const f=await harness(eventFixture(),{scheduleError:'Fictional save failure'});f.ids.get('photo-title').value='Memories';
+ for(let attempt=0;attempt<2;attempt++)await f.ids.get('photo-form').onsubmit({preventDefault(){}});
+ assert.equal(f.ids.get('photo-error').hidden,false);assert.match(f.ids.get('photo-error').textContent,/Fictional save failure/);
+ assert.equal(f.ids.get('photo-create').disabled,false);
+ const calls=f.calls.filter(call=>call.path==='/api/collections');assert.equal(calls.length,2);assert.equal(calls[0].body.requestId,calls[1].body.requestId);
+});
+
for(const method of ['ready','expand','onEvent'])test('a Telegram '+method+' bridge failure keeps Home, My events and Create usable',async()=>{
const initial=eventFixture(),message='Fictional '+method+' bridge failure';
const f=await harness(initial,{telegramErrors:{[method]:message}});
diff --git a/test/photo-sharing.test.js b/test/photo-sharing.test.js
new file mode 100644
index 0000000..80bd6e7
--- /dev/null
+++ b/test/photo-sharing.test.js
@@ -0,0 +1,95 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {Bot} from '../src/bot.js';
+import {photoCollection,photoLinks,mediaCan,mediaRole} from '../src/permissions.js';
+import {publicEvent} from '../src/mini-api.js';
+import {mediaApi} from '../src/media-api.js';
+
+function fixture(appUrl='https://example.test/app'){
+ const store={data:{events:{},sessions:{},preferences:{},users:{},offset:0}},calls=[];
+ const bot=new Bot(store,async(method,params)=>{calls.push({method,...params});return {};},'FictionalPhotosBot',appUrl);
+ const msg=(id,text,extra={})=>bot.handle({message:{chat:{id,type:'private'},from:{id,first_name:'Alex'},text,...extra}});
+ const cb=(id,data)=>bot.handle({callback_query:{id:'fictional',from:{id,first_name:'Alex'},data}});
+ return {store,bot,calls,msg,cb};
+}
+
+test('photo-only creation works from the keyboard and from Create event without a schedule or RSVP',async()=>{
+ for(const entry of ['keyboard','create']){
+ const f=fixture();
+ if(entry==='keyboard')await f.msg(1,'π· Photo sharing');
+ else{
+ await f.msg(1,'π Create event');
+ assert.ok(f.calls.some(c=>c.reply_markup?.inline_keyboard?.flat().some(b=>b.callback_data==='photos-new')));
+ await f.cb(1,'photos-new');
+ }
+ await f.msg(1,'Fictional holiday photos');
+ const e=Object.values(f.store.data.events)[0];
+ assert.equal(e.kind,'photos');assert.equal(e.startsAt,undefined);assert.equal(e.isPublic,false);assert.deepEqual(e.guests,{});
+ assert.notEqual(e.viewToken,e.uploadToken);
+ const card=f.calls.at(-1),buttons=card.reply_markup.inline_keyboard.flat();
+ assert.ok(buttons.some(b=>b.text==='Share viewer link'));assert.ok(buttons.some(b=>b.text==='Share contributor link'));
+ assert.ok(!buttons.some(b=>/ticket|RSVP|Accept/.test(b.text)));
+ assert.ok(publicEvent(e,1,'FictionalPhotosBot').photoLinks);
+ assert.equal(publicEvent(e,2,'FictionalPhotosBot').photoLinks,undefined);
+ }
+});
+
+test('viewers cannot upload via callbacks, guessed upload links or stale contributor sessions',async()=>{
+ const f=fixture(),e=photoCollection('Shared memories',1);f.store.data.events[e.id]=e;
+ await f.msg(2,'/start p_'+e.viewToken);
+ assert.equal(mediaRole(e,2,f.store.data.preferences[2]),'viewer');
+ assert.equal(f.bot.mediaCan(e,2,'viewMedia'),true);assert.equal(f.bot.mediaCan(e,2,'uploadMedia'),false);
+ assert.ok(!f.calls.at(-1).reply_markup.inline_keyboard.flat().some(b=>b.callback_data?.startsWith('media-add:')));
+ await f.cb(2,'media-add:'+e.id);assert.notEqual(f.store.data.sessions[2]?.step,'upload');
+ await f.msg(2,'/start a_'+e.id);assert.notEqual(f.store.data.sessions[2]?.step,'upload');
+ await f.cb(2,'book:'+e.id);assert.deepEqual(e.guests,{});
+ await f.msg(3,'/start u_'+e.uploadToken);await f.cb(3,'media-add:'+e.id);
+ assert.equal(f.store.data.sessions[3].step,'upload');
+ await f.msg(3,'',{photo:[{file_id:'fictional-photo',file_size:100}]});assert.equal(e.media.length,1);
+ // Simulate access changing while an old upload prompt remains in Telegram.
+ f.store.data.preferences[3].mediaAccess[e.id]=e.viewToken;
+ await f.msg(3,'',{photo:[{file_id:'fictional-denied-photo'}]});assert.equal(e.media.length,1);
+ assert.deepEqual(e.guests,{});
+ await f.msg(4,'/start e_'+e.id);assert.equal(f.bot.mediaAllowed(e,4),false);assert.deepEqual(e.guests,{});
+});
+
+test('gallery enforces role grants, shares only the viewer link and rejects invalidated tokens',async()=>{
+ const e=photoCollection('Fictional gallery',1),pref={mediaAccess:{[e.id]:e.viewToken}};
+ const env={BOT_USERNAME:'FictionalPhotosBot',DB:{prepare(sql){return {bind(){return this;},async first(){return {data:JSON.stringify(sql.includes("kind='events'")?e:pref)};}};}}};
+ const request=()=>new Request(`https://example.test/api/events/${e.id}/gallery`);
+ let response=await mediaApi(request(),env,{id:2}),body=await response.json();
+ assert.equal(response.status,200);assert.equal(body.canUpload,false);assert.equal(body.galleryUrl,photoLinks(e,env.BOT_USERNAME).viewer);
+ assert.ok(!JSON.stringify(body).includes(e.uploadToken));
+ pref.mediaAccess[e.id]=e.uploadToken;
+ body=await (await mediaApi(request(),env,{id:2})).json();assert.equal(body.canUpload,true);
+ e.uploadToken='a'.repeat(32);assert.equal((await mediaApi(request(),env,{id:2})).status,403);
+ pref.mediaAccess[e.id]=e.viewToken;e.cancelled=true;assert.equal((await mediaApi(request(),env,{id:2})).status,403);
+});
+
+test('collections fail closed without valid grants and existing event media permissions stay unchanged',()=>{
+ const e=photoCollection('Fictional',1);
+ assert.equal(mediaCan(e,2,'viewMedia'),false);assert.equal(mediaCan(e,2,'uploadMedia'),false);
+ assert.equal(mediaCan(e,1,'uploadMedia'),true);
+ const legacy={owner:1,guests:{2:{status:'yes'}},permissions:{viewMedia:true,uploadMedia:false}};
+ assert.equal(mediaCan(legacy,2,'viewMedia'),true);assert.equal(mediaCan(legacy,2,'uploadMedia'),false);
+});
+
+test('only owners change uploader visibility and hidden names never appear in gallery metadata or bot attribution',async()=>{
+ const f=fixture(''),e=photoCollection('Fictional memories',1);f.store.data.events[e.id]=e;
+ e.media.push({id:'abcdef123456',type:'photo',fileId:'fictional-media',filename:'photo',name:'Fictional uploader',by:3,caption:'Holiday sunset'});
+ await f.msg(2,'/start p_'+e.viewToken);
+ await f.cb(2,'photo-names:'+e.id);assert.equal(e.showUploaders,true);
+ await f.cb(1,'photo-names:'+e.id);assert.equal(e.showUploaders,false);
+ const pref=f.store.data.preferences[2];
+ const env={BOT_USERNAME:'FictionalPhotosBot',DB:{prepare(sql){return {bind(){return this;},async first(){return {data:JSON.stringify(sql.includes("kind='events'")?e:pref)};}};}}};
+ const request=()=>new Request(`https://example.test/api/events/${e.id}/gallery`);
+ const hidden=await(await mediaApi(request(),env,{id:2})).json();
+ assert.equal(hidden.media[0].name,null);assert.equal(hidden.media[0].by,undefined);
+ assert.ok(!JSON.stringify(hidden).includes('Fictional uploader'));assert.equal(hidden.media[0].caption,'Holiday sunset');
+ await f.cb(2,`m:${e.id}:0`);
+ assert.equal(f.calls.findLast(c=>c.method==='sendPhoto').caption,'Shared media\nHoliday sunset');
+ assert.equal(e.media[0].name,'Fictional uploader');assert.equal(e.media[0].by,3);
+ await f.cb(1,'photo-names:'+e.id);
+ const visible=await(await mediaApi(request(),env,{id:2})).json();assert.equal(visible.media[0].name,'Fictional uploader');
+ await f.cb(2,`m:${e.id}:0`);assert.match(f.calls.findLast(c=>c.method==='sendPhoto').caption,/Shared by Fictional uploader/);
+});