diff --git a/cypress/e2e/event.cy.ts b/cypress/e2e/event.cy.ts index 78cc2ca..8870164 100644 --- a/cypress/e2e/event.cy.ts +++ b/cypress/e2e/event.cy.ts @@ -86,7 +86,7 @@ describe("Events", () => { ); }); - it("allows you to attend an event", function () { + it("allows you to attend an event - visible in public list", function () { cy.get("button#attendEvent").click(); cy.get("#attendeeName").type("Test Attendee"); cy.get("#attendeeNumber").focus().clear(); @@ -99,6 +99,20 @@ describe("Events", () => { ); }); + it("allows you to attend an event - hidden from public list", function () { + cy.get("button#attendEvent").click(); + cy.get("#attendeeName").type("Test Attendee"); + cy.get("#attendeeNumber").focus().clear(); + cy.get("#attendeeNumber").type("2"); + cy.get("#attendeeVisible").uncheck(); + cy.get("form#attendEventForm").submit(); + cy.get("#attendees-alert").should("contain.text", "8 spots remaining"); + cy.get(".attendeesList").should( + "contain.text", + "Test Attendee (2 people) (hidden from public list)", + ); + }); + it("allows you to comment on an event", function () { cy.get("#commentAuthor").type("Test Author"); cy.get("#commentContent").type("Test Comment"); diff --git a/package.json b/package.json index d573f7c..60fa957 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license": "GPL-3.0-or-later", "dependencies": { "@sendgrid/mail": "^6.5.5", + "activitypub-types": "^1.0.3", "cors": "^2.8.5", "dompurify": "^3.0.6", "express": "^4.18.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f303bc0..51126fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@sendgrid/mail': specifier: ^6.5.5 version: 6.5.5 + activitypub-types: + specifier: ^1.0.3 + version: 1.0.3 cors: specifier: ^2.8.5 version: 2.8.5 @@ -875,6 +878,10 @@ packages: hasBin: true dev: true + /activitypub-types@1.0.3: + resolution: {integrity: sha512-70PzXqhskrXebCcIAxyvKeQAR8myxLlSF5GKcyN/5UkTpTPTlQyzehzd4tXFgR6ZE+Tvsy3/1WWKSPTQPXvFwA==} + dev: false + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} diff --git a/public/css/style.css b/public/css/style.css index d50ab11..dd59d6b 100755 --- a/public/css/style.css +++ b/public/css/style.css @@ -187,6 +187,28 @@ body, html { font-weight: bold; } +.attendeesList > li.hidden-attendee { + border: 4px solid #ccc; + background: #eee; +} + +.attendeesList > li.hidden-attendee a { + color: #555; +} + +.hidden-attendees-message { + display: inline-block; + border: 4px solid #ccc; + text-align: center; + border-radius: 2em; + padding: 0.5em 1em; + background: #eee; + color: #555; + font-size: 0.95em; + font-weight: bold; + margin: 0; +} + .expand { -webkit-transition: height 0.2s; -moz-transition: height 0.2s; @@ -321,6 +343,10 @@ body, html { color: #fff; } +li.hidden-attendee .attendee-name { + color: #555; +} + .remove-attendee { color: #fff; } diff --git a/src/activitypub.js b/src/activitypub.js index a6f4ada..88e3768 100644 --- a/src/activitypub.js +++ b/src/activitypub.js @@ -10,7 +10,7 @@ const domain = config.general.domain; const siteName = config.general.site_name; const isFederated = config.general.is_federated; import Event from "./models/Event.js"; -import { activityPubContentType, alternateActivityPubContentType } from "./lib/activitypub.js"; +import { handlePollResponse, activityPubContentType, alternateActivityPubContentType, getEventId, getNoteRecipient } from "./lib/activitypub.js"; // This alphabet (used to generate all event, group, etc. IDs) is missing '-' // because ActivityPub doesn't like it in IDs @@ -660,7 +660,7 @@ export function sendAcceptMessage(thebody, eventID, targetDomain, callback) { function _handleFollow(req, res) { const myURL = new URL(req.body.actor); let targetDomain = myURL.hostname; - let eventID = req.body.object.replace(`https://${domain}/`, ""); + let eventID = getEventId(req.body.object); // Add the user to the DB of accounts that follow the account // get the follower's username request( @@ -735,11 +735,22 @@ function _handleFollow(req, res) { "https://www.w3.org/ns/activitystreams", name: `RSVP to ${event.name}`, type: "Question", - content: `@${name} Will you attend ${event.name}? (If you reply "Yes", you'll be listed as an attendee on the event page.)`, + content: `@${name} Will you attend ${event.name}?`, oneOf: [ { type: "Note", - name: "Yes", + name: "Yes, and show me in the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "Yes, but hide me from the public list", + "replies": { "type": "Collection", "totalItems": 0 } + }, + { + type: "Note", + name: "No", + "replies": { "type": "Collection", "totalItems": 0 } }, ], endTime: @@ -807,7 +818,7 @@ function _handleFollow(req, res) { }); } else { // this person is already a follower so just say "ok" - return res.status(200); + return res.sendStatus(200); } }, ); @@ -867,11 +878,15 @@ function _handleUndoFollow(req, res) { } function _handleAcceptEvent(req, res) { - let { name, attributedTo, inReplyTo, to, actor } = req.body; - if (Array.isArray(to)) { - to = to[0]; + let { name, attributedTo, inReplyTo, actor } = req.body; + const recipient = getNoteRecipient(req.body); + if (!recipient) { + return res.status(400).send("No recipient found in the object"); + } + const eventID = getEventId(recipient); + if (!eventID) { + return res.status(400).send("No event ID found in the recipient"); } - const eventID = to.replace(`https://${domain}/`, ""); Event.findOne( { id: eventID, @@ -989,7 +1004,7 @@ function _handleUndoAcceptEvent(req, res) { ); if (message) { // it's a match - Event.update( + Event.updateOne( { id: eventID }, { $pull: { attendees: { id: actor } } }, ).then((response) => { @@ -1005,134 +1020,6 @@ function _handleUndoAcceptEvent(req, res) { ); } -function _handleCreateNote(req, res) { - // figure out what this is in reply to -- it should be addressed specifically to us - let { name, attributedTo, inReplyTo, to } = req.body.object; - // if it's an array just grab the first element, since a poll should only broadcast back to the pollster - if (Array.isArray(to)) { - to = to[0]; - } - const eventID = to.replace(`https://${domain}/`, ""); - // make sure this person is actually a follower - Event.findOne( - { - id: eventID, - }, - function (err, event) { - if (!event) return; - // is this even someone who follows us - const indexOfFollower = event.followers.findIndex( - (el) => el.actorId === req.body.object.attributedTo, - ); - if (indexOfFollower !== -1) { - // compare the inReplyTo to its stored message, if it exists and it's going to the right follower then this is a valid reply - const message = event.activityPubMessages.find((el) => { - const content = JSON.parse(el.content); - return inReplyTo === (content.object && content.object.id); - }); - if (message) { - const content = JSON.parse(message.content); - // check if the message we sent out was sent to the actor this incoming message is attributedTo - if (content.to[0] === attributedTo) { - // it's a match, this is a valid poll response, add RSVP to database - // fetch the profile information of the user - request( - { - url: attributedTo, - headers: { - Accept: activityPubContentType, - "Content-Type": activityPubContentType, - }, - }, - function (error, response, body) { - body = JSON.parse(body); - // if this account is NOT already in our attendees list, add it - if ( - !event.attendees - .map((el) => el.id) - .includes(attributedTo) - ) { - const attendeeName = - body.preferredUsername || - body.name || - attributedTo; - const newAttendee = { - name: attendeeName, - status: "attending", - id: attributedTo, - number: 1, - }; - event.attendees.push(newAttendee); - event - .save() - .then((fullEvent) => { - addToLog( - "addEventAttendee", - "success", - "Attendee added to event " + - req.params.eventID, - ); - // get the new attendee with its hidden id from the full event - let fullAttendee = - fullEvent.attendees.find( - (el) => - el.id === attributedTo, - ); - // send a "click here to remove yourself" link back to the user as a DM - const jsonObject = { - "@context": - "https://www.w3.org/ns/activitystreams", - name: `RSVP to ${event.name}`, - type: "Note", - content: `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here: https://${domain}/oneclickunattendevent/${event.id}/${fullAttendee._id}`, - tag: [ - { - type: "Mention", - href: newAttendee.id, - name: newAttendee.name, - }, - ], - }; - // send direct message to user - sendDirectMessage( - jsonObject, - newAttendee.id, - event.id, - ); - return res.sendStatus(200); - }) - .catch((err) => { - addToLog( - "addEventAttendee", - "error", - "Attempt to add attendee to event " + - req.params.eventID + - " failed with error: " + - err, - ); - return res - .status(500) - .send( - "Database error, please try again :(", - ); - }); - } else { - // it's a duplicate and this person is already rsvped so just say OK - return res - .status(200) - .send( - "Attendee is already registered.", - ); - } - }, - ); - } - } - } - }, - ); -} - function _handleDelete(req, res) { const deleteObjectId = req.body.object.id; // find all events with comments from the author @@ -1221,7 +1108,10 @@ function _handleCreateNoteComment(req, res) { cc.includes("https://www.w3.org/ns/activitystreams#Public")) ) { // figure out which event(s) of ours it was addressing + // Mastodon seems to put the event ID in the to field, Pleroma in the cc field + // This is because ActivityPub is a mess (love you ActivityPub) let ourEvents = cc + .concat(to) .filter((el) => el.includes(`https://${domain}/`)) .map((el) => el.replace(`https://${domain}/`, "")); // comments should only be on one event. if more than one, ignore (spam, probably) @@ -1320,28 +1210,31 @@ export function processInbox(req, res) { try { // if a Follow activity hits the inbox if (typeof req.body.object === "string" && req.body.type === "Follow") { + console.log("Sending to _handleFollow"); _handleFollow(req, res); } // if an Undo activity with a Follow object hits the inbox - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && req.body.object.type === "Follow" ) { + console.log("Sending to _handleUndoFollow"); _handleUndoFollow(req, res); } // if an Accept activity with the id of the Event we sent out hits the inbox, it is an affirmative RSVP - if ( + else if ( req.body && req.body.type === "Accept" && req.body.object && typeof req.body.object === "string" ) { + console.log("Sending to _handleAcceptEvent"); _handleAcceptEvent(req, res); } // if an Undo activity containing an Accept containing the id of the Event we sent out hits the inbox, it is an undo RSVP - if ( + else if ( req.body && req.body.type === "Undo" && req.body.object && @@ -1349,10 +1242,11 @@ export function processInbox(req, res) { typeof req.body.object.object === "string" && req.body.object.type === "Accept" ) { + console.log("Sending to _handleUndoAcceptEvent"); _handleUndoAcceptEvent(req, res); } // if a Create activity with a Note object hits the inbox, and it's a reply, it might be a vote in a poll - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && @@ -1360,22 +1254,27 @@ export function processInbox(req, res) { req.body.object.inReplyTo && req.body.object.to ) { - _handleCreateNote(req, res); + handlePollResponse(req, res); } // if a Delete activity hits the inbox, it might a deletion of a comment - if (req.body && req.body.type === "Delete") { + else if (req.body && req.body.type === "Delete") { + console.log("Sending to _handleDelete"); _handleDelete(req, res); } // if we are CC'ed on a public or unlisted Create/Note, then this is a comment to us we should boost (Announce) to our followers - if ( + else if ( req.body && req.body.type === "Create" && req.body.object && req.body.object.type === "Note" && req.body.object.to ) { + console.log("Sending to _handleCreateNoteComment"); _handleCreateNoteComment(req, res); } // CC'ed + else { + console.log("No action taken"); + } } catch (e) { console.log("Error in processing inbox:", e); } diff --git a/src/lib/activitypub.ts b/src/lib/activitypub.ts index 11f0770..a06991d 100644 --- a/src/lib/activitypub.ts +++ b/src/lib/activitypub.ts @@ -1,4 +1,18 @@ import { Request, Response } from "express"; +import Event, { IAttendee } from "../models/Event.js"; +import { sendDirectMessage } from "../activitypub.js"; +import { successfulRSVPResponse } from "./activitypub/templates.js"; + +interface APObject { + type: "Note"; + actor?: string; + id: string; + to?: string | string[]; + cc?: string | string[]; + attributedTo: string; + inReplyTo: string; + name: string; +} // From https://www.w3.org/TR/activitypub/#client-to-server-interactions: // "Servers MAY interpret a Content-Type or Accept header of application/activity+json @@ -20,3 +34,157 @@ export const acceptsActivityPub = (req: Request) => { (header) => req.headers.accept?.includes(header), ); }; + +// At least for poll responses, Mastodon stores the recipient (the poll-maker) +// in the 'to' field, while Pleroma stores it in 'cc' +export const getNoteRecipient = (object: APObject): string | null => { + const { to, cc } = object; + if (!to && !cc) { + return ""; + } + if (to && to.length > 0) { + if (Array.isArray(to)) { + return to[0]; + } + if (typeof to === "string") { + return to; + } + return null; + } else if (cc && cc.length > 0) { + if (Array.isArray(cc)) { + return cc[0]; + } + return cc; + } + return null; +}; + +// Returns the event ID from a URL like http://localhost:3000/123abc +// or https://gath.io/123abc +export const getEventId = (url: string): string => { + try { + return new URL(url).pathname.replace("/", ""); + } catch (error) { + // Apparently not a URL so maybe it's just the ID + return url; + } +}; + +export const handlePollResponse = async (req: Request, res: Response) => { + try { + // figure out what this is in reply to -- it should be addressed specifically to us + const { attributedTo, inReplyTo, name } = req.body.object as APObject; + const recipient = getNoteRecipient(req.body.object); + if (!recipient) throw new Error("No recipient found"); + + const eventID = getEventId(recipient); + const event = await Event.findOne({ id: eventID }); + if (!event) throw new Error("Event not found"); + + // make sure this person is actually a follower of the event + const senderAlreadyFollows = event.followers?.some( + (el) => el.actorId === attributedTo, + ); + if (!senderAlreadyFollows) { + throw new Error("Poll response sender does not follow event"); + } + + // compare the inReplyTo to its stored message, if it exists and + // it's going to the right follower then this is a valid reply + const matchingMessage = event.activityPubMessages?.find((el) => { + const content = JSON.parse(el.content || ""); + return inReplyTo === content?.object?.id; + }); + if (!matchingMessage) throw new Error("No matching message found"); + const messageContent = JSON.parse(matchingMessage.content || ""); + // check if the message we sent out was sent to the actor this incoming + // message is attributedTo + const messageRecipient = getNoteRecipient(messageContent.object); + if (!messageRecipient || messageRecipient !== attributedTo) { + throw new Error("Message recipient does not match attributedTo"); + } + + // it's a match, this is a valid poll response, add RSVP to database + + // 'name' is the poll response + // - "Yes, and show me in the public list", + // - "Yes, but hide me from the public list", + // - "No" + if ( + name !== "Yes, and show me in the public list" && + name !== "Yes, but hide me from the public list" && + name !== "No" + ) { + throw new Error("Invalid poll response"); + } + + if (name === "No") { + // Why did you even respond? + return res.status(200).send("Thanks I guess?"); + } + + const visibility = + name === "Yes, and show me in the public list" + ? "public" + : "private"; + + // fetch the profile information of the user + const response = await fetch(attributedTo, { + headers: { + Accept: activityPubContentType, + "Content-Type": activityPubContentType, + }, + }); + if (!response.ok) throw new Error("Actor not found"); + const apActor = await response.json(); + + // If the actor is not already attending the event, add them + if (!event.attendees?.some((el) => el.id === attributedTo)) { + const attendeeName = + apActor.preferredUsername || apActor.name || attributedTo; + const newAttendee: Partial = { + name: attendeeName, + status: "attending", + id: attributedTo, + number: 1, + visibility, + }; + const updatedEvent = await Event.findOneAndUpdate( + { id: eventID }, + { $push: { attendees: newAttendee } }, + { new: true }, + ).exec(); + const fullAttendee = updatedEvent?.attendees?.find( + (el) => el.id === attributedTo, + ); + if (!fullAttendee) throw new Error("Full attendee not found"); + + // send a "click here to remove yourself" link back to the user as a DM + const jsonObject = { + "@context": "https://www.w3.org/ns/activitystreams", + name: `RSVP to ${event.name}`, + type: "Note", + content: successfulRSVPResponse({ + event, + newAttendee, + fullAttendee, + }), + tag: [ + { + type: "Mention", + href: newAttendee.id, + name: newAttendee.name, + }, + ], + }; + // send direct message to user + sendDirectMessage(jsonObject, newAttendee.id, event.id); + return res.sendStatus(200); + } else { + return res.status(200).send("Attendee is already registered."); + } + } catch (error) { + console.error(error); + return res.status(500).send("An unexpected error occurred."); + } +}; diff --git a/src/lib/activitypub/templates.ts b/src/lib/activitypub/templates.ts new file mode 100644 index 0000000..cab9ada --- /dev/null +++ b/src/lib/activitypub/templates.ts @@ -0,0 +1,14 @@ +import { IEvent } from "../../models/Event.js"; +import getConfig from "../config.js"; +const config = getConfig(); + +export const successfulRSVPResponse = ({ + event, + newAttendee, + fullAttendee, +}: { + event: IEvent; + newAttendee: { id: string; name: string }; + fullAttendee: { _id: string }; +}) => + `@${newAttendee.name} Thanks for RSVPing! You can remove yourself from the RSVP list by clicking here.`; diff --git a/src/models/Event.ts b/src/models/Event.ts index 94be087..f67d40b 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -9,6 +9,7 @@ export interface IAttendee { number?: number; created?: Date; _id: string; + visibility?: "public" | "private"; } export interface IReply { @@ -105,6 +106,11 @@ const Attendees = new mongoose.Schema({ trim: true, default: 1, }, + visibility: { + type: String, + trim: true, + default: "public", + }, created: Date, }); diff --git a/src/routes.js b/src/routes.js index 360f387..ab12a3a 100755 --- a/src/routes.js +++ b/src/routes.js @@ -505,13 +505,12 @@ router.post("/deleteeventgroup/:eventGroupID/:editToken", (req, res) => { }, ); } - Event.update( + Event.updateOne( { _id: { $in: linkedEventIDs } }, { $set: { eventGroup: null } }, { multi: true }, ) .then((response) => { - console.log(response); addToLog( "deleteEventGroup", "success", @@ -688,6 +687,7 @@ router.post("/attendevent/:eventID", async (req, res) => { "attendees.$.name": req.body.attendeeName, "attendees.$.email": req.body.attendeeEmail, "attendees.$.number": req.body.attendeeNumber, + "attendees.$.visibility": !!req.body.attendeeVisible ? "public" : "private", }, }, ) @@ -762,12 +762,11 @@ router.post("/unattendevent/:eventID", (req, res) => { return res.sendStatus(500); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { removalPassword } } }, ) .then((response) => { - console.log(response); addToLog( "unattendEvent", "success", @@ -836,15 +835,16 @@ router.post("/unattendevent/:eventID", (req, res) => { // this is a one-click unattend that requires a secret URL that only the person who RSVPed over // activitypub knows router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { - // Mastodon will "click" links that sent to its users, presumably as a prefetch? + // Mastodon and Pleroma will "click" links that sent to its users, presumably as a prefetch? // Anyway, this ignores the automated clicks that are done without the user's knowledge if ( req.headers["user-agent"] && - req.headers["user-agent"].includes("Mastodon") + (req.headers["user-agent"].toLowerCase().includes("mastodon") || + req.headers["user-agent"].toLowerCase().includes("pleroma")) ) { return res.sendStatus(200); } - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) @@ -915,12 +915,11 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { }); router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { - Event.update( + Event.updateOne( { id: req.params.eventID }, { $pull: { attendees: { _id: req.params.attendeeID } } }, ) .then((response) => { - console.log(response); addToLog( "removeEventAttendee", "success", @@ -1071,12 +1070,11 @@ router.post("/subscribe/:eventGroupID", (req, res) => { */ router.get("/unsubscribe/:eventGroupID", (req, res) => { const email = req.query.email; - console.log(email); if (!email) { return res.sendStatus(500); } - EventGroup.update( + EventGroup.updateOne( { id: req.params.eventGroupID }, { $pull: { subscribers: { email } } }, ) @@ -1434,7 +1432,10 @@ router.post("/deletecomment/:eventID/:commentID/:editToken", (req, res) => { router.post("/activitypub/inbox", (req, res) => { if (!isFederated) return res.sendStatus(404); // validate the incoming message - const signature = req.get("Signature"); + const signature = req.get("signature"); + if (!signature) { + return res.status(401).send("No signature provided."); + } let signature_header = signature .split(",") .map((pair) => { @@ -1446,7 +1447,6 @@ router.post("/activitypub/inbox", (req, res) => { acc[el[0]] = el[1]; return acc; }, {}); - // get the actor // TODO if this is a Delete for an Actor this won't work request( @@ -1478,11 +1478,10 @@ router.post("/activitypub/inbox", (req, res) => { } }) .join("\n"); - const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(comparison_string, "ascii"); - const publicKeyBuf = new Buffer(publicKey, "ascii"); - const signatureBuf = new Buffer( + const publicKeyBuf = Buffer.from(publicKey, "ascii"); + const signatureBuf = Buffer.from( signature_header.signature, "base64", ); diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index cc97ab8..8ddfbf6 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -142,7 +142,11 @@ router.get("/:eventID", async (req: Request, res: Response) => { if (el.number && el.number > 1) { el.name = `${el.name} (${el.number} people)`; } - return el; + return { + ...el, + // Backwards compatibility - if visibility is not set, default to public + visibility: el.visibility || "public", + }; }) .filter((obj, pos, arr) => { return ( @@ -159,6 +163,24 @@ router.get("/:eventID", async (req: Request, res: Response) => { } return acc; }, 0) || 0; + const visibleAttendees = eventAttendees?.filter( + (attendee) => attendee.visibility === "public", + ); + const hiddenAttendees = eventAttendees?.filter( + (attendee) => attendee.visibility === "private", + ); + const numberOfHiddenAttendees = eventAttendees?.reduce( + (acc, attendee) => { + if ( + attendee.status === "attending" && + attendee.visibility === "private" + ) { + return acc + (attendee.number || 1); + } + return acc; + }, + 0, + ); if (event.maxAttendees) { spotsRemaining = event.maxAttendees - numberOfAttendees; if (spotsRemaining <= 0) { @@ -189,8 +211,10 @@ router.get("/:eventID", async (req: Request, res: Response) => { title: event.name, escapedName: escapedName, eventData: event, - eventAttendees: eventAttendees, + visibleAttendees, + hiddenAttendees, numberOfAttendees, + numberOfHiddenAttendees, spotsRemaining: spotsRemaining, noMoreSpots: noMoreSpots, eventStartISO: eventStartISO, diff --git a/src/start.ts b/src/start.ts index a6399ac..124a2fb 100755 --- a/src/start.ts +++ b/src/start.ts @@ -9,6 +9,7 @@ mongoose.connect(config.database.mongodb_url, { useUnifiedTopology: true, }); mongoose.set("useCreateIndex", true); +mongoose.set("useFindAndModify", false); mongoose.Promise = global.Promise; mongoose.connection .on("connected", () => { diff --git a/views/event.handlebars b/views/event.handlebars index 999a12b..4402578 100755 --- a/views/event.handlebars +++ b/views/event.handlebars @@ -131,7 +131,7 @@ {{#if eventData.usersCanAttend}}
-
Attendees {{#if eventAttendees}}({{numberOfAttendees}}){{/if}} +
Attendees {{#if numberOfAttendees}}({{numberOfAttendees}}){{/if}}
{{#unless noMoreSpots}} @@ -139,7 +139,7 @@
-
+
{{#if eventData.maxAttendees}} {{#if noMoreSpots}}
This event is at capacity.
@@ -147,12 +147,22 @@
{{spotsRemaining}} {{plural spotsRemaining "spot(s)"}} remaining - add yourself now!
{{/if}} {{/if}} - {{#if eventAttendees}} + {{#if numberOfAttendees}}
    - {{#each eventAttendees}} + {{#each visibleAttendees}} {{this.name}}{{#if ../editingEnabled}} {{/if}} {{/each}} + {{#if editingEnabled}} + {{#each hiddenAttendees}} + {{this.name}} (hidden from public list){{#if ../editingEnabled}} {{/if}} + {{/each}} + {{/if}}
+ {{#unless editingEnabled}} + {{#if numberOfHiddenAttendees}} +
{{numberOfHiddenAttendees}} hidden attendee{{plural numberOfHiddenAttendees ""}}
+ {{/if}} + {{/unless}} {{else}}

No attendees yet!

{{/if}} @@ -189,6 +199,13 @@
+
+ + +

If you choose to hide your name, only the event organiser will be able to see it.

+

You will need this password if you want to remove yourself from the list of event attendees. If you provided your email, you'll receive it by email. Otherwise, write it down now because it will not be shown again.