contact messages: visitor identity, relative timestamps, alternating headers
- The first message of a thread must carry name + email introducing the visitor; the panel shows the two fields until the thread has an identity (send stays disabled while they look empty, so the draft is never lost), prefilled from and saved to localStorage. The identity is stored on ContactThread (msgspec defaults — old databases decode unchanged) and fixed afterwards; it labels the visitor's messages instead of 'you', and the admin inbox lists threads by name with the email as a mailto in the thread header. - Timestamps use the analytics relative formatter (formatWhen), with the full date on hover. - Message headers alternate alignment (own messages right, theirs left) as the visual side cue; the sent-from page link is dropped from the display (kept as stored metadata). - Composer gains a sendDisabled prop for the intro gate.
This commit is contained in:
1 parent
e810c5f664
commit
67c19230b4
7 files changed
+173
-57
No files matched your search
@@ -14,6 +14,10 @@ const props = defineProps({
|
||||
// whose uploads go to the /_api file store instead.
|
||||
token: { type: String, default: '' },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
// When true, send (button and Ctrl+Enter) is inert and the draft is
|
||||
// kept — e.g. the contact panel's first-message name/email fields are
|
||||
// still empty.
|
||||
sendDisabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
@@ -109,7 +113,7 @@ function insertUpload({ path, image, name }) {
|
||||
|
||||
function send() {
|
||||
const md = serializeRich(box.value)
|
||||
if (!md || uploading.value) return
|
||||
if (!md || uploading.value || props.sendDisabled) return
|
||||
emit('send', md)
|
||||
box.value.innerHTML = ''
|
||||
}
|
||||
@@ -142,7 +146,7 @@ defineExpose({ focus: () => box.value?.focus() })
|
||||
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
|
||||
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
|
||||
<span v-if="uploading" class="uploading">uploading…</span>
|
||||
<button type="button" class="send" title="send (Ctrl+Enter)" @mousedown.prevent @click="send">➤</button>
|
||||
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
|
||||
</div>
|
||||
<p v-if="error" class="composer-error">{{ error }}</p>
|
||||
<input
|
||||
@@ -224,6 +228,11 @@ defineExpose({ focus: () => box.value?.focus() })
|
||||
.composer-bar button:hover {
|
||||
background: color-mix(var(--muted) 15%, transparent);
|
||||
}
|
||||
.composer-bar button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
background: none;
|
||||
}
|
||||
.composer-bar .uploading {
|
||||
margin-left: 0.4rem;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// socket (via the bridge pagerite.js hands to contact-main), with the
|
||||
// composer shared with the admin inbox. state is contact-main's reactive
|
||||
// store ({open, thread, unread}).
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { formatWhen } from './analytics/format.js'
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -22,8 +23,36 @@ async function scrollDown() {
|
||||
watch(() => props.state.thread?.messages?.length, scrollDown)
|
||||
watch(() => props.state.open, (open) => { if (open) scrollDown() })
|
||||
|
||||
function fmtTime(t) {
|
||||
return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
// Relative timestamps like in analytics; the full date is on hover.
|
||||
const fmtTime = (t) => formatWhen(t)
|
||||
const fmtFull = (t) =>
|
||||
new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
const NAME_KEY = 'pagerite-contact-name'
|
||||
const EMAIL_KEY = 'pagerite-contact-email'
|
||||
|
||||
// Name and email are asked with the first message; from then on the
|
||||
// thread's stored identity labels the visitor's messages.
|
||||
const intro = reactive({
|
||||
name: localStorage.getItem(NAME_KEY) || '',
|
||||
email: localStorage.getItem(EMAIL_KEY) || '',
|
||||
})
|
||||
const showIntro = computed(() => !props.state.thread?.name)
|
||||
// Sending stays disabled until both intro fields look filled.
|
||||
const introInvalid = computed(
|
||||
() => showIntro.value && (!intro.name.trim() || !intro.email.trim().includes('@')),
|
||||
)
|
||||
|
||||
function onSend(md) {
|
||||
if (showIntro.value) {
|
||||
const name = intro.name.trim()
|
||||
const email = intro.email.trim()
|
||||
localStorage.setItem(NAME_KEY, name)
|
||||
localStorage.setItem(EMAIL_KEY, email)
|
||||
emit('send', { md, name, email })
|
||||
return
|
||||
}
|
||||
emit('send', { md })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,19 +67,27 @@ function fmtTime(t) {
|
||||
<p v-if="!state.thread || !state.thread.messages.length" class="contact-empty">
|
||||
Send us a message — the reply appears here.
|
||||
</p>
|
||||
<div v-for="m in state.thread?.messages || []" :key="m.id" class="contact-msg">
|
||||
<div
|
||||
v-for="m in state.thread?.messages || []"
|
||||
:key="m.id"
|
||||
class="contact-msg"
|
||||
:class="{ mine: !m.admin }"
|
||||
>
|
||||
<!-- m.html is the server's strict-markdown rendering of the
|
||||
message source (pagerite/messages.py, html=False): markdown-it
|
||||
output only, never user markup. -->
|
||||
<header>
|
||||
<span class="who">{{ m.admin || 'you' }}</span>
|
||||
<a v-if="m.page" class="page" :href="m.page" :title="`sent from ${m.page}`">⌂</a>
|
||||
<time>{{ fmtTime(m.t) }}</time>
|
||||
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
|
||||
<time :title="fmtFull(m.t)">{{ fmtTime(m.t) }}</time>
|
||||
</header>
|
||||
<div class="contact-msg-body" v-html="m.html"></div>
|
||||
</div>
|
||||
</div>
|
||||
<Composer :token="token" autofocus @send="(md) => emit('send', md)" />
|
||||
<div v-if="showIntro" class="contact-intro">
|
||||
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
|
||||
<input v-model="intro.email" type="email" placeholder="Your email" maxlength="200">
|
||||
</div>
|
||||
<Composer :token="token" :send-disabled="introInvalid" autofocus @send="onSend" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -143,15 +180,32 @@ function fmtTime(t) {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.contact-msg.mine header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.contact-msg header .who {
|
||||
font-weight: 600;
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
.contact-msg header .page {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
|
||||
.contact-intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.contact-msg header .page:hover {
|
||||
color: var(--accent);
|
||||
.contact-intro input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--bg, Canvas);
|
||||
color: var(--text, CanvasText);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.contact-intro input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
// server's strict-markdown rendering — injected verbatim, never user
|
||||
// markup (pagerite/messages.py).
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { formatWhen } from './analytics/format.js'
|
||||
import Composer from './Composer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -56,9 +57,10 @@ function preview(t) {
|
||||
return (who + (div.textContent || '')).slice(0, 80)
|
||||
}
|
||||
|
||||
function fmtTime(t) {
|
||||
return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
// Relative timestamps like elsewhere in analytics; full date on hover.
|
||||
const fmtTime = (t) => formatWhen(t)
|
||||
const fmtFull = (t) =>
|
||||
new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
function send(md) {
|
||||
if (thread.value) emit('reply', { id: thread.value.id, text: md })
|
||||
@@ -79,20 +81,28 @@ function send(md) {
|
||||
>
|
||||
<span class="dot" :class="{ unread: t.unread }"></span>
|
||||
<span class="peek">
|
||||
<strong>{{ t.id.slice(0, 8) }}</strong>
|
||||
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
|
||||
{{ preview(t) }}
|
||||
</span>
|
||||
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
|
||||
<time :title="fmtFull(lastMsg(t)?.t || t.created)">{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
|
||||
</button>
|
||||
</aside>
|
||||
<section v-if="thread" class="thread">
|
||||
<header v-if="thread.name" class="thread-head">
|
||||
<strong>{{ thread.name }}</strong>
|
||||
<a v-if="thread.email" :href="`mailto:${thread.email}`">{{ thread.email }}</a>
|
||||
</header>
|
||||
<div ref="scroll" class="thread-messages">
|
||||
<div v-for="m in thread.messages" :key="m.id" class="contact-msg">
|
||||
<div
|
||||
v-for="m in thread.messages"
|
||||
:key="m.id"
|
||||
class="contact-msg"
|
||||
:class="{ mine: m.admin }"
|
||||
>
|
||||
<!-- Server-rendered strict markdown only (see ContactPanel). -->
|
||||
<header>
|
||||
<span class="who">{{ m.admin || 'visitor' }}</span>
|
||||
<a v-if="m.page" class="page" :href="m.page" title="sent from this page">{{ m.page }}</a>
|
||||
<time>{{ fmtTime(m.t) }}</time>
|
||||
<span class="who">{{ m.admin || thread.name || 'visitor' }}</span>
|
||||
<time :title="fmtFull(m.t)">{{ fmtTime(m.t) }}</time>
|
||||
</header>
|
||||
<div class="contact-msg-body" v-html="m.html"></div>
|
||||
</div>
|
||||
@@ -183,6 +193,22 @@ function send(md) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.thread-head {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.thread-head a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.thread-head a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.contact-msg {
|
||||
padding: 0.55rem 0;
|
||||
}
|
||||
@@ -215,19 +241,13 @@ function send(md) {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.contact-msg.mine header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.contact-msg header .who {
|
||||
font-weight: 600;
|
||||
color: var(--text, CanvasText);
|
||||
}
|
||||
.contact-msg header .page {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.contact-msg header .page:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.messages-view {
|
||||
|
||||
@@ -41,7 +41,7 @@ let token = ''
|
||||
let solverPromise = null
|
||||
let powSolution = null // Promise<string> solving the current challenge
|
||||
let powAborter = null
|
||||
let unsent = null // last message text, retried once if its solution was rejected
|
||||
let unsent = null // last message frame, retried once if its solution was rejected
|
||||
|
||||
// The solution for the currently outstanding challenge; rides out a
|
||||
// challenge replacement (reconnect) by continuing with the new solve.
|
||||
@@ -57,10 +57,10 @@ async function currentSolution() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(md) {
|
||||
unsent = md
|
||||
async function sendMessage(md, name = '', email = '') {
|
||||
unsent = { md, name, email }
|
||||
const pow = await currentSolution()
|
||||
if (pow) bridge.send({ token, msg: md, pow })
|
||||
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
|
||||
}
|
||||
|
||||
async function onPow(msg) {
|
||||
@@ -73,10 +73,10 @@ async function onPow(msg) {
|
||||
// A rejected send (stale or already burned solution) is retried once
|
||||
// the fresh challenge is solved; a second rejection drops it.
|
||||
if (msg.required && unsent) {
|
||||
const md = unsent
|
||||
const { md, name, email } = unsent
|
||||
unsent = null
|
||||
const pow = await currentSolution()
|
||||
if (pow) bridge.send({ token, msg: md, pow })
|
||||
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function open(br) {
|
||||
app = createApp(ContactPanel, {
|
||||
state,
|
||||
token,
|
||||
onSend: (md) => sendMessage(md),
|
||||
onSend: ({ md, name, email }) => sendMessage(md, name, email),
|
||||
onClose: () => {
|
||||
state.open = false
|
||||
refreshBadges()
|
||||
|
||||
Reference in new issue
Block a user