bug reports and contacts

This commit is contained in:
Joey Kimsey
2026-09-12 17:11:35 -04:00
parent 0f5a33d6db
commit 7634219706
8 changed files with 424 additions and 85 deletions

View File

@ -13,8 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists. - Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA. POSTs send session CSRF (`X-CSRF-Token`); HTTP uses Electron `net.fetch` so the PHP session cookie persists.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token. - Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site. - Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data. - After sign-in, Capsule loads `GET /api/profile` plus notifications, the messages inbox, and `GET /api/messages/recent` for the header dropdown (avatars + unread count). Inbox rows can mark read/unread or hide. Compose and reply follow `canSend`. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) and post through `POST /api/contact` and `POST /api/bugreport`. Those footer items appear only when profile `features` (or `GET /api/contact` / `GET /api/bugreport`) say the plugin is available, accepting submissions, and allowed for this user. A hash to a disabled page shows the same “not accepting … right now” notice as the site. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box. - Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, and Terms of Service. Contact and Report a Bug join that band only when the site is accepting them. There is no subscribe box.
### Changed
- Footer upper band is Contact Us on the left, dark mode in the middle, and More Info on the right, in three equal columns. Contact and Report a Bug stay off the menu until the connected site says those plugins are accepting submissions.
- Profile page: space the white panel below the main nav, and cap the avatar at 200×200.
### Fixed ### Fixed

View File

@ -29,7 +29,7 @@ All HTTP runs in the **main process** (`net.fetch`, so the PHP session cookie st
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. | | Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Avatar and prefs. | | Profile save | `POST /api/profile/update` | Avatar and prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. | | Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, unread, delete. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. | | Contact / bugs | `GET` / `POST /api/contact`, `GET` / `POST /api/bugreport` | Footer pages when those plugins are enabled, accepting, and allowed. Hashing in while they are off shows a not-accepting notice. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. | | Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored. The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored.
@ -44,7 +44,7 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
| `src/preload/` | `window.capsule` bridge | | `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views | | `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views |
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app. The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: Contact Us on the left (Contact and Report a Bug only when the site is accepting them), dark-mode in the middle, More Info (Privacy Policy, Terms of Service) on the right. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app.
## Remote ## Remote

View File

@ -8,6 +8,8 @@ import {
createMessage, createMessage,
deleteMessage, deleteMessage,
deleteNotification, deleteNotification,
getBugreportStatus,
getContactStatus,
getProfile, getProfile,
isDeadTokenError, isDeadTokenError,
listMessages, listMessages,
@ -221,22 +223,30 @@ export function registerApiIpc() {
) )
}) })
ipcMain.handle('capsule:contactStatus', async () => {
return runUserApi((session) => getContactStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:contact', async (_event, payload) => { ipcMain.handle('capsule:contact', async (_event, payload) => {
return runUserApi((session) => return runUserApi((session) =>
sendContact(session.siteUrl, session.token, { sendContact(session.siteUrl, session.token, {
name: payload?.name || '', name: payload?.name || '',
entry: payload?.entry || '', entry: payload?.entry || '',
email: payload?.email || '' contactEmail: payload?.contactEmail || payload?.email || ''
}) })
) )
}) })
ipcMain.handle('capsule:bugreportStatus', async () => {
return runUserApi((session) => getBugreportStatus(session.siteUrl, session.token))
})
ipcMain.handle('capsule:bugreport', async (_event, payload) => { ipcMain.handle('capsule:bugreport', async (_event, payload) => {
return runUserApi((session) => return runUserApi((session) =>
sendBugreport(session.siteUrl, session.token, { sendBugreport(session.siteUrl, session.token, {
url: payload?.url || '', url: payload?.url || '',
ourl: payload?.ourl || '', ourl: payload?.ourl || '',
repeat: payload?.repeat ? 'true' : 'false', repeat: payload?.repeat,
entry: payload?.entry || '' entry: payload?.entry || ''
}) })
) )

View File

@ -263,6 +263,14 @@ export function apiErrorMessage(code, errors) {
return 'This action needs a personal (user) token, not an app token.' return 'This action needs a personal (user) token, not an app token.'
case 'not found': case 'not found':
return 'This site is missing that Capsule API action. Pull the latest TTP on the server.' return 'This site is missing that Capsule API action. Pull the latest TTP on the server.'
case 'Contact submissions are disabled.':
return 'The contact form is not accepting new messages right now.'
case 'Bug report submissions are disabled.':
return 'New bug reports are not being accepted right now.'
case 'Contact is not available':
return 'The contact form is not available.'
case 'Bug reports is not available':
return 'Bug reports are not available.'
default: default:
return code || 'The site returned an error.' return code || 'The site returned an error.'
} }
@ -779,24 +787,54 @@ export async function searchSite(siteUrl, token, query) {
}) })
} }
/**
* Contact plugin status. GET api/contact.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getContactStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/contact', method: 'GET', token })
}
/** /**
* Contact form. POST api/contact. * Contact form. POST api/contact.
* *
* @param {string} siteUrl - canonical site base * @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token * @param {string} token - Bearer token
* @param {object} fields - name, entry, optional email * @param {object} fields - name, entry, optional contactEmail or email
* @return {Promise<object>} * @return {Promise<object>}
*/ */
export async function sendContact(siteUrl, token, fields) { export async function sendContact(siteUrl, token, fields) {
const form = {
name: fields?.name || '',
entry: fields?.entry || ''
}
const email = fields?.contactEmail || fields?.email || ''
if (email) {
form.contactEmail = email
}
return ttpRequest({ return ttpRequest({
siteUrl, siteUrl,
path: '/api/contact', path: '/api/contact',
method: 'POST', method: 'POST',
token, token,
form: fields form
}) })
} }
/**
* Bug report plugin status. GET api/bugreport.
*
* @param {string} siteUrl - canonical site base
* @param {string} token - Bearer token
* @return {Promise<object>}
*/
export async function getBugreportStatus(siteUrl, token) {
return ttpRequest({ siteUrl, path: '/api/bugreport', method: 'GET', token })
}
/** /**
* Bug report. POST api/bugreport. * Bug report. POST api/bugreport.
* *
@ -806,11 +844,18 @@ export async function sendContact(siteUrl, token, fields) {
* @return {Promise<object>} * @return {Promise<object>}
*/ */
export async function sendBugreport(siteUrl, token, fields) { export async function sendBugreport(siteUrl, token, fields) {
const url = fields?.url || ''
const ourl = fields?.ourl || url
return ttpRequest({ return ttpRequest({
siteUrl, siteUrl,
path: '/api/bugreport', path: '/api/bugreport',
method: 'POST', method: 'POST',
token, token,
form: fields form: {
url,
ourl,
repeat: fields?.repeat === true || fields?.repeat === 'true' ? 'true' : 'false',
entry: fields?.entry || ''
}
}) })
} }

View File

@ -234,16 +234,34 @@ const capsule = {
return ipcRenderer.invoke('capsule:search', payload) return ipcRenderer.invoke('capsule:search', payload)
}, },
/**
* Contact plugin status. GET api/contact.
*
* @return {Promise<object>}
*/
contactStatus() {
return ipcRenderer.invoke('capsule:contactStatus')
},
/** /**
* Contact form submit. * Contact form submit.
* *
* @param {object} payload - name, entry, email * @param {object} payload - name, entry, contactEmail or email
* @return {Promise<object>} * @return {Promise<object>}
*/ */
contact(payload) { contact(payload) {
return ipcRenderer.invoke('capsule:contact', payload) return ipcRenderer.invoke('capsule:contact', payload)
}, },
/**
* Bug report plugin status. GET api/bugreport.
*
* @return {Promise<object>}
*/
bugreportStatus() {
return ipcRenderer.invoke('capsule:bugreportStatus')
},
/** /**
* Bug report submit. * Bug report submit.
* *

View File

@ -358,8 +358,8 @@
<div class="m-2 m-lg-4"> <div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4"> <div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4">
<h1 class="h2 text-center mb-4">Contact Us</h1> <h1 class="h2 text-center mb-4">Contact Us</h1>
<p id="contact-unavailable" class="text-muted text-center" hidden></p> <p id="contact-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="contact-body"> <div id="contact-body" hidden>
<div class="col-12 col-lg-6 offset-lg-3"> <div class="col-12 col-lg-6 offset-lg-3">
<p class="text-center text-lg-start"> <p class="text-center text-lg-start">
Here at <strong id="contact-sitename">this site</strong>, we highly value your Here at <strong id="contact-sitename">this site</strong>, we highly value your
@ -381,6 +381,7 @@
name="name" name="name"
type="text" type="text"
required required
maxlength="20"
autocomplete="name" autocomplete="name"
/> />
</div> </div>
@ -393,7 +394,7 @@
<input <input
id="contact-email" id="contact-email"
class="form-control" class="form-control"
name="email" name="contactEmail"
type="email" type="email"
autocomplete="email" autocomplete="email"
/> />
@ -428,8 +429,8 @@
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg"> <div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h2 text-center mb-4">Report a Bug</h1> <h1 class="h2 text-center mb-4">Report a Bug</h1>
<hr /> <hr />
<p id="bug-unavailable" class="text-muted text-center" hidden></p> <p id="bug-unavailable" class="alert alert-warning text-center" hidden></p>
<div id="bug-body"> <div id="bug-body" hidden>
<p class="text-center text-sm-start"> <p class="text-center text-sm-start">
Thank you for visiting our bug reporting page. We value our users' input highly and Thank you for visiting our bug reporting page. We value our users' input highly and
in an effort to better serve your needs, please fill out the form below to help us in an effort to better serve your needs, please fill out the form below to help us
@ -599,7 +600,8 @@
</section> </section>
<section id="view-profile" class="view" hidden> <section id="view-profile" class="view" hidden>
<div class="container p-4 context-main-bg mb-4 text-center"> <div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4 text-center">
<h1 class="h3 mb-4">Profile</h1> <h1 class="h3 mb-4">Profile</h1>
<hr /> <hr />
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -615,8 +617,9 @@
id="profile-avatar" id="profile-avatar"
src="" src=""
alt="User Pic" alt="User Pic"
class="rounded-circle img-fluid" class="rounded-circle capsule-profile-avatar"
style="max-width: 150px" width="200"
height="200"
/> />
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
@ -646,6 +649,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<section id="view-settings" class="view" hidden> <section id="view-settings" class="view" hidden>
@ -746,19 +750,26 @@
</div> </div>
<div class="collapse d-md-block my-4" id="footerMenu"> <div class="collapse d-md-block my-4" id="footerMenu">
<div class="row"> <div class="capsule-footer-cols">
<div id="footer-contact-col" class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center"> <div id="footer-contact-col" class="capsule-footer-contact" hidden>
<h2 class="h5">Contact Us</h2> <h2 class="h5">Contact Us</h2>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2"> <li id="footer-link-contact" class="nav-item mb-2" hidden>
<a href="#/contact" class="nav-link p-0 text-muted">Contact</a> <a href="#/contact" class="nav-link p-0 text-muted">Contact</a>
</li> </li>
<li id="footer-link-bug" class="nav-item mb-2"> <li id="footer-link-bug" class="nav-item mb-2" hidden>
<a href="#/bugreport" class="nav-link p-0 text-muted">Report a Bug</a> <a href="#/bugreport" class="nav-link p-0 text-muted">Report a Bug</a>
</li> </li>
</ul> </ul>
</div> </div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center"> <div class="capsule-footer-theme">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
<div class="capsule-footer-info">
<h2 class="h5">More Info</h2> <h2 class="h5">More Info</h2>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item mb-2"> <li class="nav-item mb-2">
@ -783,13 +794,6 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -50,6 +50,10 @@ const previewButton = document.getElementById('preview-shell')
let session = null let session = null
let profile = { ...demoProfile } let profile = { ...demoProfile }
let plugins = [] let plugins = []
let features = {
contact: { available: false, accepting: false, canSubmit: false },
bugreport: { available: false, accepting: false, canSubmit: false }
}
let notifications = { items: [], unread: 0 } let notifications = { items: [], unread: 0 }
let messages = { items: [], unread: 0 } let messages = { items: [], unread: 0 }
let recentMessages = null let recentMessages = null
@ -122,6 +126,130 @@ function hasPlugin(name) {
return plugins.includes(name) return plugins.includes(name)
} }
/**
* Normalize Capsule feature flags from the API.
*
* @param {object} [raw] - available, accepting, canSubmit
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function normalizeFeature(raw) {
return {
available: Boolean(raw?.available),
accepting: Boolean(raw?.accepting),
canSubmit: Boolean(raw?.canSubmit)
}
}
/**
* Status for contact or bugreport from profile, GET, or the plugin list.
*
* @param {string} name - contact or bugreport
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function featureState(name) {
return features[name] || { available: false, accepting: false, canSubmit: false }
}
/**
* True when that footer item should appear (plugin on, accepting, permitted).
*
* @param {string} name - contact or bugreport
* @return {boolean}
*/
function featureInMenu(name) {
if (session?.preview) {
return true
}
const state = featureState(name)
return state.available && state.accepting && state.canSubmit
}
/**
* Copy for a contact or bugreport page that is not taking submissions.
*
* @param {string} name - contact or bugreport
* @return {string}
*/
function featureGateMessage(name) {
const state = featureState(name)
if (name === 'contact') {
if (!state.available) {
return 'The contact form is not available.'
}
if (!state.accepting) {
return 'The contact form is not accepting new messages right now.'
}
if (!state.canSubmit) {
return 'You do not have permission to use the contact form.'
}
return ''
}
if (!state.available) {
return 'Bug reports are not available.'
}
if (!state.accepting) {
return 'New bug reports are not being accepted right now.'
}
if (!state.canSubmit) {
return 'You do not have permission to report bugs.'
}
return ''
}
/**
* Store contact/bugreport flags from profile JSON or a GET status body.
*
* @param {object} [body] - GET/POST api/profile or GET api/contact|bugreport
* @return {void}
*/
function applyFeatures(body) {
if (body?.features && typeof body.features === 'object') {
features = {
contact: normalizeFeature(body.features.contact),
bugreport: normalizeFeature(body.features.bugreport)
}
return
}
features = {
contact: featureFromPlugin('contact'),
bugreport: featureFromPlugin('bugreport')
}
}
/**
* Merge one GET status payload into local feature flags.
*
* @param {string} name - contact or bugreport
* @param {object} [body] - GET api/contact or GET api/bugreport
* @return {void}
*/
function applyFeatureStatus(name, body) {
if (body && typeof body === 'object' && !body.error) {
if ('available' in body || 'accepting' in body || 'canSubmit' in body) {
features = {
...features,
[name]: normalizeFeature(body)
}
return
}
}
features = {
...features,
[name]: featureFromPlugin(name)
}
}
/**
* Feature flags from the enabled-plugin list when the site has no features blob.
*
* @param {string} name - plugin folder
* @return {{available: boolean, accepting: boolean, canSubmit: boolean}}
*/
function featureFromPlugin(name) {
const on = hasPlugin(name)
return { available: on, accepting: on, canSubmit: on }
}
/** /**
* Site path that opens in the system browser. * Site path that opens in the system browser.
* *
@ -267,6 +395,7 @@ function timezoneList() {
function applyProfile(body) { function applyProfile(body) {
const user = body?.user || {} const user = body?.user || {}
plugins = Array.isArray(body?.plugins) ? body.plugins : [] plugins = Array.isArray(body?.plugins) ? body.plugins : []
applyFeatures(body)
profile = { profile = {
id: user.id, id: user.id,
username: user.username || session?.username || '', username: user.username || session?.username || '',
@ -462,11 +591,11 @@ function renderFooter() {
} }
}) })
const showContact = Boolean(session?.connected) && (session.preview || hasPlugin('contact')) const contactOn = featureInMenu('contact')
const showBug = Boolean(session?.connected) && (session.preview || hasPlugin('bugreport')) const bugOn = featureInMenu('bugreport')
document.getElementById('footer-link-contact').hidden = !showContact document.getElementById('footer-link-contact').hidden = !contactOn
document.getElementById('footer-link-bug').hidden = !showBug document.getElementById('footer-link-bug').hidden = !bugOn
document.getElementById('footer-contact-col').hidden = !showContact && !showBug document.getElementById('footer-contact-col').hidden = !contactOn && !bugOn
} }
/** /**
@ -564,19 +693,18 @@ function renderPages() {
? `Enabled plugins: ${plugins.join(', ')}` ? `Enabled plugins: ${plugins.join(', ')}`
: 'Plugin list will show after profile loads.' : 'Plugin list will show after profile loads.'
const contactOk = session.preview || hasPlugin('contact') const contactOk = featureInMenu('contact')
const bugOk = session.preview || hasPlugin('bugreport') const bugOk = featureInMenu('bugreport')
document.getElementById('contact-body').hidden = !contactOk document.getElementById('contact-body').hidden = !contactOk
document.getElementById('bug-body').hidden = !bugOk document.getElementById('bug-body').hidden = !bugOk
const contactUnavailable = document.getElementById('contact-unavailable') const contactUnavailable = document.getElementById('contact-unavailable')
const bugUnavailable = document.getElementById('bug-unavailable') const bugUnavailable = document.getElementById('bug-unavailable')
contactUnavailable.hidden = contactOk contactUnavailable.hidden = contactOk
bugUnavailable.hidden = bugOk bugUnavailable.hidden = bugOk
contactUnavailable.textContent = contactOk ? '' : 'Contact is not available on this site.' contactUnavailable.textContent = contactOk ? '' : featureGateMessage('contact')
bugUnavailable.textContent = bugOk ? '' : 'Bug reports are not available on this site.' bugUnavailable.textContent = bugOk ? '' : featureGateMessage('bugreport')
if (contactOk && !document.getElementById('contact-name').value) { if (contactOk && !document.getElementById('contact-email').value && profile.email) {
document.getElementById('contact-name').value = profile.username || session.username || '' document.getElementById('contact-email').value = profile.email
document.getElementById('contact-email').value = profile.email || ''
} }
if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) { if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) {
document.getElementById('bug-url').value = session.siteUrl document.getElementById('bug-url').value = session.siteUrl
@ -888,10 +1016,16 @@ function route() {
} }
if (path === 'contact') { if (path === 'contact') {
showView('contact') showView('contact')
if (!session.preview) {
loadFeatureStatus('contact')
}
return return
} }
if (path === 'bugreport') { if (path === 'bugreport') {
showView('bugreport') showView('bugreport')
if (!session.preview) {
loadFeatureStatus('bugreport')
}
return return
} }
if (path === 'search') { if (path === 'search') {
@ -928,6 +1062,10 @@ function applySession(next) {
window.location.hash = '' window.location.hash = ''
applyTheme(false) applyTheme(false)
plugins = [] plugins = []
features = {
contact: { available: false, accepting: false, canSubmit: false },
bugreport: { available: false, accepting: false, canSubmit: false }
}
notifications = { items: [], unread: 0 } notifications = { items: [], unread: 0 }
messages = { items: [], unread: 0 } messages = { items: [], unread: 0 }
recentMessages = null recentMessages = null
@ -951,7 +1089,11 @@ function applySession(next) {
recentMessages = { items: demoMessages, unread: messages.unread } recentMessages = { items: demoMessages, unread: messages.unread }
canSendMessages = true canSendMessages = true
profile = { ...demoProfile } profile = { ...demoProfile }
plugins = [] plugins = ['contact', 'bugreport']
features = {
contact: { available: true, accepting: true, canSubmit: true },
bugreport: { available: true, accepting: true, canSubmit: true }
}
} else if (session?.connected && window.capsule) { } else if (session?.connected && window.capsule) {
loadWorkspace() loadWorkspace()
} }
@ -992,6 +1134,33 @@ async function loadWorkspace() {
} }
} }
/**
* Recheck whether contact or bug reports are accepting submissions.
*
* @param {string} name - contact or bugreport
* @return {Promise<void>}
*/
async function loadFeatureStatus(name) {
if (!window.capsule || session?.preview) {
return
}
try {
const data = await runApi(() =>
name === 'contact' ? window.capsule.contactStatus() : window.capsule.bugreportStatus()
)
if (!data) {
return
}
applyFeatureStatus(name, data)
renderChrome()
renderPages()
} catch {
applyFeatureStatus(name, null)
renderChrome()
renderPages()
}
}
/** /**
* Load one conversation. * Load one conversation.
* *
@ -1362,6 +1531,11 @@ document.getElementById('contact-form').addEventListener('submit', async (event)
const button = event.target.querySelector('button[type="submit"]') const button = event.target.querySelector('button[type="submit"]')
button.disabled = true button.disabled = true
setMessage(status, '') setMessage(status, '')
if (!featureInMenu('contact')) {
setMessage(status, featureGateMessage('contact'), 'text-danger')
button.disabled = false
return
}
if (session?.preview || !window.capsule) { if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted') setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false button.disabled = false
@ -1371,14 +1545,18 @@ document.getElementById('contact-form').addEventListener('submit', async (event)
await runApi(() => await runApi(() =>
window.capsule.contact({ window.capsule.contact({
name: document.getElementById('contact-name').value.trim(), name: document.getElementById('contact-name').value.trim(),
email: document.getElementById('contact-email').value.trim(), contactEmail: document.getElementById('contact-email').value.trim(),
entry: document.getElementById('contact-entry').value entry: document.getElementById('contact-entry').value
}) })
) )
document.getElementById('contact-entry').value = '' document.getElementById('contact-entry').value = ''
setMessage(status, 'Sent.', 'text-success') setMessage(status, 'Sent.', 'text-success')
} catch (err) { } catch (err) {
setMessage(status, err?.message || 'Could not send that message.', 'text-danger') const msg = err?.message || 'Could not send that message.'
if (/disabled|not available|permission/i.test(msg)) {
loadFeatureStatus('contact')
}
setMessage(status, msg, 'text-danger')
} finally { } finally {
button.disabled = false button.disabled = false
} }
@ -1390,16 +1568,23 @@ document.getElementById('bugreport-form').addEventListener('submit', async (even
const button = event.target.querySelector('button[type="submit"]') const button = event.target.querySelector('button[type="submit"]')
button.disabled = true button.disabled = true
setMessage(status, '') setMessage(status, '')
if (!featureInMenu('bugreport')) {
setMessage(status, featureGateMessage('bugreport'), 'text-danger')
button.disabled = false
return
}
if (session?.preview || !window.capsule) { if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted') setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false button.disabled = false
return return
} }
try { try {
const url = document.getElementById('bug-url').value.trim()
const ourl = document.getElementById('bug-ourl').value.trim() || url
await runApi(() => await runApi(() =>
window.capsule.bugreport({ window.capsule.bugreport({
url: document.getElementById('bug-url').value.trim(), url,
ourl: document.getElementById('bug-ourl').value.trim(), ourl,
repeat: document.getElementById('bug-repeat-yes').checked, repeat: document.getElementById('bug-repeat-yes').checked,
entry: document.getElementById('bug-entry').value entry: document.getElementById('bug-entry').value
}) })
@ -1407,7 +1592,11 @@ document.getElementById('bugreport-form').addEventListener('submit', async (even
document.getElementById('bug-entry').value = '' document.getElementById('bug-entry').value = ''
setMessage(status, 'Sent.', 'text-success') setMessage(status, 'Sent.', 'text-success')
} catch (err) { } catch (err) {
setMessage(status, err?.message || 'Could not send that report.', 'text-danger') const msg = err?.message || 'Could not send that report.'
if (/disabled|not available|permission/i.test(msg)) {
loadFeatureStatus('bugreport')
}
setMessage(status, msg, 'text-danger')
} finally { } finally {
button.disabled = false button.disabled = false
} }

View File

@ -107,6 +107,63 @@ footer a.context-main {
text-decoration: none; text-decoration: none;
} }
/**
* Footer upper band: Contact Us, dark mode, More Info.
*/
.capsule-footer-cols {
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
'contact'
'theme'
'info';
gap: 1.5rem;
text-align: center;
}
.capsule-footer-contact {
grid-area: contact;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-areas:
'theme'
'info';
}
.capsule-footer-theme {
grid-area: theme;
}
.capsule-footer-info {
grid-area: info;
}
@media (min-width: 768px) {
.capsule-footer-cols {
grid-template-columns: repeat(3, 1fr);
grid-template-areas: 'contact theme info';
align-items: start;
}
.capsule-footer-contact {
text-align: start;
}
.capsule-footer-theme {
text-align: center;
}
.capsule-footer-info {
text-align: end;
}
.capsule-footer-cols:not(:has(#footer-contact-col:not([hidden]))) {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'theme info';
}
}
/** /**
* Dark-mode footer switch. * Dark-mode footer switch.
*/ */
@ -361,6 +418,17 @@ header .form-select:focus {
flex-shrink: 0; flex-shrink: 0;
} }
/**
* Profile page avatar.
*/
.capsule-profile-avatar {
width: 200px;
height: 200px;
max-width: 200px;
max-height: 200px;
object-fit: cover;
}
.is-unread { .is-unread {
font-weight: 600; font-weight: 600;
} }