Compare commits

...
1 Commits
Author SHA1 Message Date
d 722b7b9a73 implemented complete version of UI for email messenger 2026-07-13 19:43:08 +03:00
26 changed files with 5964 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: GPL
package web
import (
"net/http"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const tplMail templates.TplName = "mail"
// Mail renders the M8SH Mail frontend page. The page itself is a thin shell;
// all UI and mock state live client-side in the Vue MailApp component.
func Mail(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("mail_title")
ctx.Data["PageIsMail"] = true
ctx.HTML(http.StatusOK, tplMail)
}
+3
View File
@@ -542,6 +542,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/pulls", reqSignIn, user.Pulls)
m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones)
// M8SH Mail: messenger-style mail UI, mock-only frontend, requires sign-in.
m.Get("/mail", reqSignIn, Mail)
// ***** START: User *****
// "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..."
m.Get("/user/login", auth.SignIn)
+4
View File
@@ -27,6 +27,10 @@
{{end}}
{{end}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
<!-- M8SH Mail: "Messages" link to the right of explore, always visible when signed in -->
{{if and .IsSigned (not .MustChangePassword)}}
<a class="item{{if .PageIsMail}} active{{end}}" href="{{AppSubUrl}}/mail">Chats</a>
{{end}}
{{else if .IsLandingPageOrganizations}}
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/organizations">{{ctx.Locale.Tr "explore_title"}}</a>
{{else}}
+5
View File
@@ -0,0 +1,5 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content mail-page">
<div id="mail-app"></div>
</div>
{{template "base/footer" .}}
+1
View File
@@ -84,6 +84,7 @@
@import "./explore.css";
@import "./review.css";
@import "./actions.css";
@import "./mail.css";
@import "./helpers.css";
+292
View File
@@ -0,0 +1,292 @@
/* Copyright 2026 The M8SH Authors. */
/* SPDX-License-Identifier: GPL-3.0 */
/* The mail page fills the viewport below the navbar and above the footer.
The body is a flex column: [navbar wrapper .full.height] then [footer].
.full.height has flex-grow:1, so it already fills everything except the footer.
We make it a flex container so the mail page can flex:1 within it, sitting below the navbar. */
/* When the mail page is active, make .full.height a column flex container so
the navbar stays fixed at top and the mail page fills the remaining height. */
.full.height:has(.mail-page) {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 0; /* mail manages its own bottom spacing */
}
.page-content.mail-page {
flex: 1;
min-height: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.page-content.mail-page > #mail-app {
flex: 1;
min-height: 0;
}
/* On mobile, hide the footer so the mail app uses the full viewport height. */
@media (max-width: 639.98px) {
.full.height:has(.mail-page) ~ .page-footer {
display: none;
}
}
/* In-thread search highlight. Global (not scoped) because it must reach
<mark> nodes produced via v-html inside bubbles and html cards. */
#mail-app mark.search-hit {
background: var(--color-highlight-bg);
color: var(--color-text);
border-radius: 2px;
padding: 0 1px;
}
#mail-app mark.search-hit.current {
outline: 1px solid var(--color-highlight-fg);
}
/* Thread context menu. It is teleported to <body> (outside the Vue scoped
component subtree), so its styles live here, unscoped. */
body > .ctx-menu {
position: fixed;
z-index: 1002;
min-width: 140px;
background: var(--color-menu);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
body > .ctx-menu .ctx-item {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
padding: 7px 10px;
border-radius: var(--border-radius);
cursor: pointer;
text-align: left;
min-height: 36px;
}
body > .ctx-menu .ctx-item:hover {
background: var(--color-hover);
}
body > .ctx-menu .ctx-item.danger:hover {
color: var(--color-danger);
}
/* Delete conversation confirmation dialog (teleported, so unscoped). */
body > .confirm-overlay {
position: fixed;
inset: 0;
z-index: 1003;
background: var(--color-overlay-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
body > .confirm-overlay .confirm-modal {
width: 100%;
max-width: 360px;
background: var(--color-menu);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
body > .confirm-overlay .confirm-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--border-radius-full);
background: var(--color-red-badge-bg);
color: var(--color-danger);
}
body > .confirm-overlay .confirm-title {
font-size: 15px;
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
body > .confirm-overlay .confirm-desc {
font-size: 13px;
line-height: 1.4;
color: var(--color-text-light-2);
text-align: center;
}
body > .confirm-overlay .confirm-actions {
display: flex;
gap: 8px;
width: 100%;
margin-top: 4px;
}
body > .confirm-overlay .confirm-cancel {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-secondary-alpha-30);
background: var(--color-body);
color: var(--color-text);
font-size: 13px;
padding: 8px 12px;
border-radius: var(--border-radius-medium);
cursor: pointer;
min-height: 38px;
}
body > .confirm-overlay .confirm-cancel:hover {
background: var(--color-hover);
}
body > .confirm-overlay .confirm-delete {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
background: var(--color-danger);
color: var(--color-white);
font-size: 13px;
padding: 8px 12px;
border-radius: var(--border-radius-medium);
cursor: pointer;
min-height: 38px;
}
body > .confirm-overlay .confirm-delete:hover {
opacity: 0.9;
}
/* Block toggle row: a right-aligned chevron that flips up when expanded. */
body > .ctx-menu .ctx-toggle .ctx-chev {
margin-left: auto;
color: var(--color-text-light-2);
transition: transform 0.12s ease;
}
body > .ctx-menu .ctx-toggle .ctx-chev.up {
transform: rotate(180deg);
}
/* Expandable Block panel: its option buttons go edge-to-edge on hover so the
whole row is the target, not just the centered label. */
body > .ctx-menu .ctx-expand {
display: flex;
flex-direction: column;
gap: 0;
padding-top: 2px;
border-top: 0.5px solid var(--color-secondary-alpha-30);
margin-top: 2px;
}
body > .ctx-menu .ctx-expand .ctx-opt {
width: 100%;
border-radius: 0;
padding-left: 10px; /* flush with the other menu items' text, no icon column */
}
body > .ctx-menu .ctx-expand .ctx-opt:hover {
width: 100%;
background: var(--color-hover);
}
/* Rename group dialog input */
body > .confirm-overlay .rename-input {
width: 100%;
font-size: 14px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 8px 10px;
outline: none;
}
body > .confirm-overlay .rename-input:focus {
border-color: var(--color-primary);
}
body > .confirm-overlay .rename-modal {
align-items: stretch;
}
/* Mobile overflow menu: transparent full-screen click catcher with the menu
positioned in the top-right corner, just like the context menu (no darkening). */
body > .overflow-backdrop {
position: fixed;
inset: 0;
z-index: 1002;
}
body > .overflow-backdrop .overflow-menu {
position: absolute;
top: 56px;
right: 8px;
min-width: 160px;
background: var(--color-menu);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
body > .overflow-backdrop .ctx-item {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
padding: 7px 10px;
border-radius: var(--border-radius);
cursor: pointer;
text-align: left;
min-height: 36px;
}
body > .overflow-backdrop .ctx-item:hover {
background: var(--color-hover);
}
body > .overflow-backdrop .ctx-item.danger:hover {
color: var(--color-danger);
}
/* Audio player buttons: instant hover, no delay (overrides all inherited transitions) */
#mail-app .audio-player a.ap-btn,
#mail-app .audio-player button.ap-btn,
#mail-app .audio-player a.ap-download,
#mail-app .audio-player .ap-download,
#mail-app .audio-player .ap-close,
#mail-app .audio-player .ap-btn,
#mail-app .audio-player .ap-btn:hover {
transition: none !important;
transition-property: none !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
+444
View File
@@ -0,0 +1,444 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, ref, computed, onMounted, onBeforeUnmount} from 'vue';
import {SvgIcon} from '../../svg.ts';
import {cloneMock, domainOf, type Attachment, type BlockEntry, type BlockScope, type Message, type ForwardedContent, type Thread} from './types.ts';
import {otherParticipant} from './threadTitle.ts';
import ThreadList from './components/ThreadList.vue';
import ChatPane from './components/ChatPane.vue';
import ComposePanel from './components/ComposePanel.vue';
// Root component: holds the in-memory thread store, the responsive two-column
// layout (full-screen list / chat on mobile), and wires all child events.
export default defineComponent({
name: 'MailApp',
components: {SvgIcon, ThreadList, ChatPane, ComposePanel},
setup() {
// All session state lives in memory only; no localStorage/cookies.
const threads = ref<Thread[]>(cloneMock());
const activeId = ref<string>('');
const view = ref<'list' | 'chat'>('list'); // mobile navigation toggle
const searchQuery = ref('');
const composeOpen = ref(false);
// Block list (in-memory, mock-only). Stored as a reactive array of entries.
const blocked = ref<BlockEntry[]>([]);
const isMobile = ref(false);
function updateMobile() {
isMobile.value = window.innerWidth < 640;
}
updateMobile();
const activeThread = computed(() => threads.value.find((t) => t.id === activeId.value) ?? null);
// blockEntryForEmail reports whether an email is blocked, either by exact
// address ('user') or by domain ('domain'). Returns the entry or undefined.
function blockEntryForEmail(email: string): BlockEntry | undefined {
const lower = email.toLowerCase();
const dom = domainOf(email);
return blocked.value.find((b) => {
if (b.scope === 'user') return b.email.toLowerCase() === lower;
return b.scope === 'domain' && b.email.toLowerCase() === dom;
});
}
function isThreadBlocked(t: Thread): boolean {
if (t.isGroup) return false;
return !!blockEntryForEmail(otherParticipant(t).email);
}
// blockScopeOf returns the active block scope for a thread's participant,
// or null when not blocked. Used to show "Blocked" vs "Blocked (domain)".
function blockScopeOf(t: Thread): BlockScope | null {
if (t.isGroup) return null;
const entry = blockEntryForEmail(otherParticipant(t).email);
return entry ? entry.scope : null;
}
function selectThread(id: string) {
const t = threads.value.find((x) => x.id === id);
if (!t) return;
activeId.value = id;
t.unread = false;
if (isMobile.value) view.value = 'chat';
}
function goBack() {
view.value = 'list';
}
function togglePin(id: string) {
const t = threads.value.find((x) => x.id === id);
if (t) t.pinned = !t.pinned;
}
function markUnread(id: string) {
const t = threads.value.find((x) => x.id === id);
if (t) t.unread = true;
}
function deleteThread(id: string) {
const idx = threads.value.findIndex((x) => x.id === id);
if (idx < 0) return;
threads.value.splice(idx, 1);
if (activeId.value === id) {
activeId.value = '';
if (isMobile.value) view.value = 'list';
}
}
// blockThread adds a block entry; optionally deletes the conversation and any
// other conversation sharing the blocked user/domain.
function blockThread(id: string, scope: BlockScope, alsoDelete: boolean) {
const t = threads.value.find((x) => x.id === id);
if (!t || t.isGroup) return;
const other = otherParticipant(t);
const key = scope === 'domain' ? domainOf(other.email) : other.email;
if (!key) return;
if (!blocked.value.some((b) => b.scope === scope && b.email === key)) {
blocked.value.push({email: key, scope});
}
if (alsoDelete) {
// remove all DM threads whose other participant is now blocked
const surviving: Thread[] = [];
for (const th of threads.value) {
if (th.isGroup) { surviving.push(th); continue; }
if (blockEntryForEmail(otherParticipant(th).email)) continue;
surviving.push(th);
}
threads.value = surviving;
if (activeId.value && !threads.value.some((x) => x.id === activeId.value)) {
activeId.value = '';
if (isMobile.value) view.value = 'list';
}
}
}
// unblockThread removes both user- and domain-level entries matching the
// thread's other participant, so the "Blocked" indicator clears.
function unblockThread(id: string) {
const t = threads.value.find((x) => x.id === id);
if (!t || t.isGroup) return;
const other = otherParticipant(t);
const dom = domainOf(other.email);
blocked.value = blocked.value.filter((b) => {
if (b.scope === 'user') return b.email.toLowerCase() !== other.email.toLowerCase();
return b.email.toLowerCase() !== dom;
});
}
// onForward appends forwarded messages to a target thread. When multiple
// messages are selected they are merged into a SINGLE forwarded message:
// each source becomes a nested ForwardedContent layer (in selection order),
// so the target receives one envelope whose `forwarded` chain contains all
// of them. An optional note becomes the envelope's body text. The forwarder
// is the current user (self).
function onForward(targetId: string, msgs: Message[], note?: string) {
const target = threads.value.find((x) => x.id === targetId);
if (!target) return;
const self = target.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
// Build forwarded content for each selected message. When multiple
// messages are selected they become SIBLINGS in a `quotes` array (flat,
// same-level group) rather than a deep nested chain. A single forward
// still uses `forwarded` for backward compatibility.
const quotes: ForwardedContent[] = msgs.map((m) => ({
from: m.sender,
subject: m.subject,
body: m.body,
timestamp: m.timestamp,
forwarded: m.forwarded,
}));
const envelope: {forwarded?: ForwardedContent; quotes?: ForwardedContent[]} = {};
if (quotes.length === 1) envelope.forwarded = quotes[0];
else if (quotes.length > 1) envelope.quotes = quotes;
target.messages.push({
id: 'fwd-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
sender: self,
kind: 'text',
subject: target.subject,
body: note || (msgs.length > 1 ? `Forwarded ${msgs.length} messages` : 'Forwarded message'),
timestamp: Date.now(),
...envelope,
});
target.unread = false;
}
// onDeleteSelected removes the given message ids from the active thread.
function onDeleteSelected(ids: string[]) {
const t = activeThread.value;
if (!t) return;
const set = new Set(ids);
t.messages = t.messages.filter((m) => !set.has(m.id));
}
// renameGroup updates a group thread's subject to the new name and pushes a
// name-change meta message so the conversation reflects the rename inline.
function renameGroup(id: string, newName: string) {
const t = threads.value.find((x) => x.id === id);
if (!t || !t.isGroup) return;
const self = t.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
t.messages.push({
id: 'rename-' + Date.now(),
sender: self,
kind: 'name-change',
subject: newName,
body: newName,
timestamp: Date.now(),
});
t.subject = newName;
}
// addMemberToGroup adds a participant to a group thread by email.
function addMemberToGroup(id: string, email: string) {
const t = threads.value.find((x) => x.id === id);
if (!t || !t.isGroup) return;
if (t.participants.some((p) => p.email === email)) return;
const name = email.slice(0, email.indexOf('@'));
t.participants.push({email, name});
}
// removeMemberFromGroup removes a participant from a group thread.
function removeMemberFromGroup(id: string, email: string) {
const t = threads.value.find((x) => x.id === id);
if (!t || !t.isGroup) return;
t.participants = t.participants.filter((p) => p.email !== email);
}
function onSend(body: string, attachments?: Attachment[]) {
const t = activeThread.value;
if (!t) return;
const self = t.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
t.messages.push({
id: 'msg-' + Date.now(),
sender: self,
kind: 'text',
subject: t.subject,
body,
timestamp: Date.now(),
attachments: attachments && attachments.length ? attachments : undefined,
});
}
function onCreateThread(newThread: Thread) {
threads.value.unshift(newThread);
selectThread(newThread.id);
composeOpen.value = false;
}
// Close compose on Escape / outside click (outside handled here, inside stops propagation).
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') composeOpen.value = false;
}
function onWindowClick() {
composeOpen.value = false;
}
onMounted(() => {
window.addEventListener('resize', updateMobile);
window.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateMobile);
window.removeEventListener('keydown', onKeydown);
});
return {
threads, activeId, activeThread, view, searchQuery, composeOpen, isMobile,
selectThread, goBack, togglePin, markUnread, deleteThread, onSend, onCreateThread,
onWindowClick, blocked, isThreadBlocked, blockScopeOf, blockThread, unblockThread,
onForward, onDeleteSelected,
renameGroup,
addMemberToGroup, removeMemberFromGroup,
};
},
});
</script>
<template>
<div class="mail-app" :class="{'view-chat': view === 'chat'}" @click="onWindowClick">
<aside class="left-panel">
<div class="search-bar" @click.stop>
<input v-model="searchQuery" class="search-input" placeholder="Filter conversations…">
<i
class="compose-toggle"
:class="{open: composeOpen}"
:title="composeOpen ? 'Close composer' : 'New conversation'"
@click="composeOpen = !composeOpen"
>
<SvgIcon name="octicon-chevron-up" :size="22"/>
</i>
</div>
<ComposePanel v-if="composeOpen" @create="onCreateThread" @close="composeOpen = false"/>
<ThreadList
:threads="threads"
:active-id="activeId"
:query="searchQuery"
:blocked="blocked"
:is-thread-blocked="isThreadBlocked"
:block-scope-of="blockScopeOf"
@select="selectThread"
@toggle-pin="togglePin"
@mark-unread="markUnread"
@delete="deleteThread"
@block="blockThread"
@unblock="unblockThread"
/>
</aside>
<main class="right-panel">
<ChatPane
v-if="activeThread"
:key="activeThread.id"
:thread="activeThread"
:is-mobile="isMobile"
:all-threads="threads"
@back="goBack"
@toggle-pin="togglePin(activeThread!.id)"
@delete="deleteThread(activeThread!.id)"
@send="onSend"
@forward="onForward"
@delete-selected="onDeleteSelected"
@rename-group="renameGroup(activeThread!.id, $event)"
@add-member="addMemberToGroup(activeThread!.id, $event)"
@remove-member="removeMemberFromGroup(activeThread!.id, $event)"
/>
<div v-else class="empty-chat">
<SvgIcon name="octicon-mail" :size="32"/>
<p>Select a conversation to start reading</p>
</div>
</main>
</div>
</template>
<style scoped>
.mail-app {
display: flex;
height: 100%;
min-height: 0;
background: var(--color-body);
}
/* Desktop: two columns side by side */
.left-panel {
width: 272px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 0.5px solid var(--color-secondary-alpha-30);
background: var(--color-body);
height: 100%;
min-height: 0;
}
.right-panel {
flex: 1;
min-width: 0;
height: 100%;
min-height: 0;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
}
.search-input {
flex: 1;
min-width: 0;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 7px 10px;
outline: none;
}
.search-input:focus {
border-color: var(--color-primary);
}
.compose-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-light-2);
flex-shrink: 0;
padding: 4px;
border-radius: var(--border-radius);
transform: rotate(180deg); /* chevron-up pointing down = "closed, tap to fold open" */
transition: transform 0.15s ease;
}
.compose-toggle.open {
transform: rotate(0deg); /* pointing up = "open, tap to fold back up" */
}
.compose-toggle:hover {
color: var(--color-text);
background: var(--color-hover);
}
.empty-chat {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--color-text-light-2);
font-size: 13px;
}
/* Mobile: stack and slide */
@media (max-width: 639.98px) {
.mail-app {
position: relative;
overflow: hidden;
}
.left-panel,
.right-panel {
width: 100%;
flex-shrink: 0;
border-right: none;
}
.left-panel {
position: absolute;
inset: 0;
z-index: 1;
transform: translateX(0);
transition: transform 0.25s ease;
}
.right-panel {
position: absolute;
inset: 0;
z-index: 2;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.mail-app.view-chat .left-panel {
transform: translateX(-100%);
}
.mail-app.view-chat .right-panel {
transform: translateX(0);
}
}
</style>
@@ -0,0 +1,435 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, ref, computed, watch, onMounted, onBeforeUnmount} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Attachment} from '../types.ts';
// Floating audio player bar. Layout: prev/play/next | [name / seek] | download/close
export default defineComponent({
name: 'AudioPlayer',
components: {SvgIcon},
props: {
tracks: {type: Array as PropType<Attachment[]>, required: true},
startIndex: {type: Number, default: 0},
},
emits: ['close', 'playing-track'],
setup(props, {emit}) {
const audio = ref<HTMLAudioElement | null>(null);
const index = ref(props.startIndex);
const playing = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const current = computed(() => props.tracks[index.value] ?? null);
const progress = computed(() => {
if (!duration.value) return 0;
return Math.min(100, (currentTime.value / duration.value) * 100);
});
function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function togglePlay() {
const el = audio.value;
if (!el) return;
if (playing.value) { el.pause(); playing.value = false; }
else { el.play().then(() => { playing.value = true; }).catch(() => {}); }
}
function prev() {
if (props.tracks.length === 0) return;
index.value = (index.value - 1 + props.tracks.length) % props.tracks.length;
currentTime.value = 0;
}
function next() {
if (props.tracks.length === 0) return;
index.value = (index.value + 1) % props.tracks.length;
currentTime.value = 0;
}
function onTimeUpdate() {
const el = audio.value;
if (!el) return;
currentTime.value = el.currentTime;
}
function onLoaded() {
const el = audio.value;
if (!el) return;
duration.value = el.duration;
}
function onEnded() {
playing.value = false;
if (index.value < props.tracks.length - 1) {
next();
setTimeout(() => {
const el2 = audio.value;
if (el2) { el2.play().then(() => { playing.value = true; }).catch(() => {}); }
}, 100);
}
}
function onSeek(e: Event) {
const el = audio.value;
if (!el) return;
const val = Number((e.target as HTMLInputElement).value);
el.currentTime = val;
currentTime.value = val;
}
// downloadTrack: create a temporary <a> to trigger download without the
// hover lag that <a data-href> causes with large base64 data URLs.
function downloadTrack() {
const t = current.value;
if (!t) return;
const a = document.createElement('a');
a.href = t.url;
a.download = t.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// onSeekClick: click anywhere on the seek bar to jump to that position.
function onSeekClick(e: MouseEvent) {
const el = audio.value;
if (!el || !duration.value) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const val = pct * duration.value;
el.currentTime = val;
currentTime.value = val;
}
// Emit the current track id so the gallery can highlight it
function emitTrack() {
const t = current.value;
if (t) emit('playing-track', t.id);
}
watch(index, () => {
currentTime.value = 0;
playing.value = false;
emitTrack();
setTimeout(() => {
const el = audio.value;
if (el) {
el.load();
el.play().then(() => { playing.value = true; }).catch(() => {});
}
}, 50);
});
// Auto-play on mount — the watch above only fires on changes, not initial value
onMounted(() => {
emitTrack();
setTimeout(() => {
const el = audio.value;
if (el) {
el.load();
el.play().then(() => { playing.value = true; }).catch(() => {});
}
}, 100);
});
// Watch the startIndex prop so clicking a different audio tile switches the
// player to that track even when the player is already open.
watch(() => props.startIndex, (newIdx) => {
if (newIdx !== index.value) {
index.value = newIdx;
}
});
onBeforeUnmount(() => {
if (audio.value) {
audio.value.pause();
audio.value = null;
}
});
return {
audio, index, current, playing, currentTime, duration, progress,
formatTime, togglePlay, prev, next,
onTimeUpdate, onLoaded, onEnded, onSeek, onSeekClick, emitTrack, downloadTrack,
};
},
});
</script>
<template>
<div class="audio-player">
<audio
ref="audio"
:src="current?.url"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoaded"
@ended="onEnded"
@canplay="onLoaded"
/>
<div class="ap-controls">
<!-- Left: prev/play/next -->
<div class="ap-buttons">
<button v-if="tracks.length > 1" type="button" class="ap-btn" title="Previous" style="transition:none!important" @click="prev">
<SvgIcon name="octicon-triangle-down" :size="20" class="ap-prev-icon"/>
</button>
<button type="button" class="ap-play-btn" :title="playing ? 'Pause' : 'Play'" @click="togglePlay">
<span v-if="playing" class="ap-icon-stop"/>
<span v-else class="ap-icon-play"/>
</button>
<button v-if="tracks.length > 1" type="button" class="ap-btn" title="Next" style="transition:none!important" @click="next">
<SvgIcon name="octicon-triangle-down" :size="20" class="ap-next-icon"/>
</button>
</div>
<!-- Center: name on top, seek bar below -->
<div class="ap-center">
<span class="ap-name">{{ current?.name ?? '' }}</span>
<div class="ap-seek-row">
<span class="ap-time">{{ formatTime(currentTime) }}</span>
<div class="ap-seek-wrap" @click="onSeekClick">
<div class="ap-seek-track">
<div class="ap-seek-fill" :style="{width: progress + '%'}"/>
<div class="ap-seek-handle" :style="{left: progress + '%'}"/>
</div>
</div>
<span class="ap-time">{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Download (left on mobile) -->
<div class="ap-download-wrap">
<button v-if="current" type="button" class="ap-btn" title="Download" style="transition:none!important" @click="downloadTrack">
<SvgIcon name="octicon-download" :size="16"/>
</button>
</div>
<!-- Close (right always) -->
<div class="ap-close-wrap">
<button type="button" class="ap-btn" title="Close" style="transition:none!important" @click="$emit('close')">
<SvgIcon name="octicon-x" :size="16"/>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.audio-player,
.audio-player *,
.audio-player *::before,
.audio-player *::after {
transition-duration: 0s !important;
transition-delay: 0s !important;
}
.audio-player {
display: flex;
padding: 8px 14px;
background: var(--color-secondary-bg);
border-top: 0.5px solid var(--color-secondary-alpha-30);
flex-shrink: 0;
}
.ap-controls {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
/* Left buttons group */
.ap-buttons {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* Center: name + seek stacked */
.ap-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.ap-name {
font-size: 12px;
font-weight: var(--font-weight-medium);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-seek-row {
display: flex;
align-items: center;
gap: 6px;
}
.ap-download-wrap {
display: flex;
flex-shrink: 0;
}
.ap-close-wrap {
display: flex;
flex-shrink: 0;
}
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-light-2);
transition-duration: 0s !important;
width: 36px;
height: 36px;
border-radius: var(--border-radius);
cursor: pointer;
padding: 0;
flex-shrink: 0;
text-decoration: none;
}
.ap-btn:hover {
background: var(--color-hover);
color: var(--color-text);
transition-duration: 0s !important;
}
.ap-prev-icon {
transform: rotate(90deg);
}
.ap-next-icon {
transform: rotate(-90deg);
}
/* Play button: bare triangle, no circle */
.ap-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: var(--color-primary);
transition-duration: 0s !important;
color: var(--color-white);
width: 36px;
height: 36px;
border-radius: var(--border-radius);
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition-duration: 0s !important;
}
.ap-play-btn:hover {
background: var(--color-primary);
opacity: 0.85;
}
.ap-icon-play {
width: 0;
height: 0;
border-style: solid;
border-width: 8px 0 8px 12px;
border-color: transparent transparent transparent var(--color-white);
margin-left: 3px;
}
.ap-icon-stop {
display: flex;
gap: 4px;
}
.ap-icon-stop::before,
.ap-icon-stop::after {
content: '';
width: 5px;
height: 16px;
background: var(--color-white);
border-radius: 2px;
}
.ap-time {
font-size: 11px;
color: var(--color-text-light-2);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Seek bar with blue progress fill */
.ap-seek-wrap {
flex: 1;
min-width: 0;
position: relative;
height: 16px;
display: flex;
align-items: center;
cursor: pointer;
}
.ap-seek-track {
width: 100%;
height: 6px;
background: var(--color-secondary-alpha-30);
border-radius: var(--border-radius-full);
position: relative;
}
.ap-seek-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary);
border-radius: var(--border-radius-full);
}
.ap-seek-handle {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
transform: translate(-50%, -50%);
pointer-events: none;
}
/* Mobile: download on far left, close on far right, controls in between */
@media (max-width: 639.98px) {
.ap-controls {
flex-wrap: wrap;
justify-content: space-between;
}
.ap-download-wrap {
order: 1;
flex: 0 0 auto;
}
.ap-buttons {
order: 2;
flex: 0 0 auto;
margin: 0 auto; /* center the prev/play/next group */
}
.ap-close-wrap {
order: 3;
flex: 0 0 auto;
}
.ap-center {
flex-basis: 100%;
order: 10;
margin-top: 6px;
}
}
</style>
@@ -0,0 +1,112 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, computed, ref} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import {useAvatarResolver, DEFAULT_AVATAR_URL} from '../useAvatarResolver.ts';
// Renders an avatar for an email or a group. Always renders immediately with
// the fallback letter/icon circle and reactively swaps to an <img> once the
// avatar URL resolves. When resolution fails, the instance default gopher avatar is shown.
export default defineComponent({
name: 'AvatarCircle',
components: {SvgIcon},
props: {
email: {type: String, default: ''}, // for DM participants
name: {type: String, default: ''}, // display name, falls back to localpart
isGroup: {type: Boolean, default: false},
size: {type: Number, default: 36}, // px
},
setup(props) {
const {get, resolve} = useAvatarResolver();
// Kick off resolution on first render for an email (idempotent, non-blocking).
if (props.email && !props.isGroup) resolve(props.email);
const hue = computed(() => {
const source = (props.email || props.name || '?').toLowerCase();
let sum = 0;
for (let i = 0; i < source.length; i++) sum += source.charCodeAt(i);
return sum % 360;
});
const letter = computed(() => {
const label = props.name || localpart(props.email) || '?';
return label.charAt(0).toUpperCase();
});
const imgError = ref(false);
// The URL to render: real avatar if resolved, instance default gopher on fallback.
// '' from the resolver means "resolved-to-fallback" -> use the default gopher image.
const resolvedUrl = computed(() => {
if (props.isGroup || imgError.value) return undefined;
const url = get(props.email);
if (url === undefined) return undefined; // still pending -> letter circle
return url || DEFAULT_AVATAR_URL; // '' -> default gopher
});
return {hue, letter, resolvedUrl, imgError};
},
});
function localpart(email: string): string {
const at = email.indexOf('@');
return at < 0 ? email : email.slice(0, at);
}
</script>
<template>
<div
class="avatar-circle"
:class="{group: isGroup}"
:style="{
width: size + 'px',
height: size + 'px',
fontSize: Math.round(size * 0.42) + 'px',
background: isGroup ? `hsl(${hue}, 55%, 45%)` : resolvedUrl ? undefined : `hsl(${hue}, 55%, 45%)`,
}"
:aria-label="isGroup ? name : (name || email)"
role="img"
>
<SvgIcon v-if="isGroup" name="octicon-organization" :size="Math.round(size * 0.5)"/>
<img
v-else-if="resolvedUrl"
:src="resolvedUrl"
:alt="name || email"
class="avatar-img"
@error="imgError = true"
>
<span v-else class="avatar-letter">{{ letter }}</span>
</div>
</template>
<style scoped>
.avatar-circle {
flex-shrink: 0;
border-radius: var(--border-radius-full);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #fff;
user-select: none;
}
.avatar-circle.group {
color: #fff;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-letter {
font-weight: var(--font-weight-medium);
line-height: 1;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,411 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, ref, computed, onMounted, onBeforeUnmount} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Thread} from '../types.ts';
import AvatarCircle from './AvatarCircle.vue';
type Tab = 'direct' | 'group';
// Compose panel: toggled below the search bar. Closes on Escape or outside click.
// Emits "create" with a fully-formed Thread to be inserted into the list, and "close".
export default defineComponent({
name: 'ComposePanel',
components: {SvgIcon, AvatarCircle},
emits: ['create', 'close'],
setup(_props, {emit}) {
const tab = ref<Tab>('direct');
// Direct tab state
const directEmail = ref('');
const directBody = ref('');
const directSubject = ref('');
const editingSubject = ref(false);
const autoSubject = computed(() => {
const first = directBody.value.trim().split(/[.!?\n]/)[0] ?? '';
return first.slice(0, 60).trim();
});
const effectiveSubject = computed(() => directSubject.value || autoSubject.value);
// Group tab state
const groupName = ref('');
const groupEmailInput = ref('');
const groupChips = ref<string[]>([]);
const groupBody = ref('');
function setTab(t: Tab) {
tab.value = t;
}
function autoGrow(e: Event) {
const el = e.target as HTMLTextAreaElement;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function commitSubjectIfEditing() {
editingSubject.value = false;
}
function addChip() {
// Trim and strip any trailing delimiter (comma, semicolon, space) the user may have typed
const v = groupEmailInput.value.trim().replace(/[,;\s]+$/, '');
if (v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !groupChips.value.includes(v)) {
groupChips.value.push(v);
}
groupEmailInput.value = '';
}
function onGroupInputKeydown(e: KeyboardEvent) {
// Delimiters that commit the current input as a chip
if (e.key === 'Enter' || e.key === ',' || e.key === ';' || e.key === ' ') {
e.preventDefault();
addChip();
} else if (e.key === 'Backspace' && groupEmailInput.value === '' && groupChips.value.length) {
groupChips.value.pop();
}
}
function removeChip(idx: number) {
groupChips.value.splice(idx, 1);
}
function createDirect() {
const email = directEmail.value.trim();
if (!email || !directBody.value.trim()) return;
const name = email.slice(0, email.indexOf('@'));
const thread: Thread = {
id: 'dm-' + email.toLowerCase() + '-' + Date.now(),
isGroup: false,
subject: effectiveSubject.value || 'New conversation',
pinned: false,
unread: false,
participants: [
{email: 'd@m8sh.su', name: 'Danila', self: true},
{email, name},
],
messages: [{
id: 'c-' + Date.now(),
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: effectiveSubject.value,
body: directBody.value.trim(),
timestamp: Date.now(),
}],
};
emit('create', thread);
reset();
}
function createGroup() {
if (!groupName.value.trim() || groupChips.value.length === 0 || !groupBody.value.trim()) return;
const participants = [
{email: 'd@m8sh.su', name: 'Danila', self: true},
...groupChips.value.map((email) => ({email, name: email.slice(0, email.indexOf('@'))})),
];
const thread: Thread = {
id: 'group-' + Date.now(),
isGroup: true,
subject: groupName.value.trim(),
pinned: false,
unread: false,
participants,
messages: [{
id: 'c-' + Date.now(),
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: groupName.value.trim(),
body: groupBody.value.trim(),
timestamp: Date.now(),
}],
};
emit('create', thread);
reset();
}
function reset() {
directEmail.value = '';
directBody.value = '';
directSubject.value = '';
editingSubject.value = false;
groupName.value = '';
groupEmailInput.value = '';
groupChips.value = [];
groupBody.value = '';
emit('close');
}
// Close on Escape; outside click handled by parent via backdrop.
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
return {
tab, setTab,
directEmail, directBody, directSubject, editingSubject,
autoSubject, effectiveSubject, commitSubjectIfEditing,
groupName, groupEmailInput, groupChips, groupBody,
autoGrow, addChip, onGroupInputKeydown, removeChip,
createDirect, createGroup, reset,
};
},
});
</script>
<template>
<div class="compose-panel" @click.stop>
<div class="compose-tabs">
<button type="button" class="compose-tab" :class="{active: tab === 'direct'}" @click="setTab('direct')">
Direct
</button>
<button type="button" class="compose-tab" :class="{active: tab === 'group'}" @click="setTab('group')">
Group
</button>
</div>
<div v-if="tab === 'direct'" class="compose-body">
<input v-model="directEmail" class="compose-input" type="email" placeholder="To: email address">
<div class="compose-subject">
<template v-if="!editingSubject">
<span
v-if="effectiveSubject"
class="subject-preview"
title="Click to edit"
@click="editingSubject = true; directSubject = effectiveSubject"
>Subject: {{ effectiveSubject }}</span>
</template>
<input
v-else
v-model="directSubject"
class="compose-input subject-input"
placeholder="Subject"
@blur="commitSubjectIfEditing"
@keydown.enter="commitSubjectIfEditing"
>
</div>
<textarea
v-model="directBody"
class="compose-textarea"
placeholder="Write a message…"
rows="2"
@input="autoGrow"
/>
<div class="compose-actions">
<button type="button" class="compose-send" :disabled="!directEmail || !directBody" @click="createDirect">Send</button>
</div>
</div>
<div v-else class="compose-body">
<input v-model="groupName" class="compose-input" placeholder="Group name">
<div class="chip-field">
<span v-for="(chip, idx) in groupChips" :key="chip" class="chip">
<AvatarCircle :email="chip" :size="16"/>
<span class="chip-email">{{ chip }}</span>
<button type="button" class="chip-remove" :title="'Remove ' + chip" @click="removeChip(idx)">
<SvgIcon name="octicon-x" :size="12"/>
</button>
</span>
<input
v-model="groupEmailInput"
class="chip-input"
type="email"
placeholder="Add member email (Enter, comma, semicolon, space)"
@keydown="onGroupInputKeydown"
@blur="addChip"
>
</div>
<textarea
v-model="groupBody"
class="compose-textarea"
placeholder="Write a message…"
rows="2"
@input="autoGrow"
/>
<div class="compose-actions">
<button
type="button"
class="compose-send"
:disabled="!groupName || !groupChips.length || !groupBody"
@click="createGroup"
>
Create group
</button>
</div>
</div>
</div>
</template>
<style scoped>
.compose-panel {
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
background: var(--color-body);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.compose-tabs {
display: flex;
gap: 4px;
}
.compose-tab {
flex: 1;
border: none;
background: transparent;
color: var(--color-text-light-2);
font-size: 13px;
padding: 6px;
border-radius: var(--border-radius);
cursor: pointer;
}
.compose-tab.active {
background: var(--color-secondary-bg);
color: var(--color-text);
font-weight: var(--font-weight-medium);
}
.compose-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.compose-input,
.compose-textarea {
width: 100%;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 8px 10px;
outline: none;
}
.compose-input:focus,
.compose-textarea:focus {
border-color: var(--color-primary);
}
.chip-input {
flex: 1;
min-width: 140px;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: transparent;
border: none;
outline: none;
padding: 4px;
}
.compose-textarea {
resize: none;
overflow-y: auto;
min-height: 36px;
max-height: 140px;
line-height: 1.4;
}
.compose-subject {
min-height: 0;
}
.subject-preview {
font-size: 11px;
font-style: italic;
color: var(--color-text-light-2);
cursor: text;
display: inline-block;
max-width: 100%;
}
.subject-input {
font-style: italic;
}
.chip-field {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 4px 6px;
}
.chip-field:focus-within {
border-color: var(--color-primary);
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
border-radius: var(--border-radius-full);
padding: 1px 4px 1px 2px;
font-size: 12px;
}
.chip-email {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip-remove {
border: none;
background: transparent;
color: var(--color-text-light-2);
cursor: pointer;
padding: 2px;
display: inline-flex;
border-radius: var(--border-radius-full);
}
.chip-remove:hover {
color: var(--color-danger);
}
.compose-actions {
display: flex;
justify-content: flex-end;
}
.compose-send {
border: none;
background: hsl(214, 70%, 93%);
color: hsl(214, 70%, 30%);
font-size: 13px;
font-weight: var(--font-weight-medium);
padding: 6px 14px;
border-radius: var(--border-radius-medium);
cursor: pointer;
}
.compose-send:disabled {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
</style>
@@ -0,0 +1,341 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, ref, nextTick} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Thread} from '../types.ts';
import {threadDisplayName, threadAvatarEmail} from '../threadTitle.ts';
import AvatarCircle from './AvatarCircle.vue';
// Modal dialog for picking a target thread to forward selected messages into.
// Two-step: first pick a target, then (optionally) add a note that accompanies
// the forwarded content. Emits `pick(target, note)` with the chosen target and
// note, or `close` to cancel.
export default defineComponent({
name: 'ForwardDialog',
components: {SvgIcon, AvatarCircle},
props: {
threads: {type: Array as PropType<Thread[]>, required: true},
currentId: {type: String, default: ''},
selectedCount: {type: Number, default: 0},
},
emits: ['pick', 'close'],
setup(_, {emit}) {
const query = ref('');
const step = ref<'target' | 'note'>('target');
const chosen = ref<Thread | null>(null);
const note = ref('');
const noteInput = ref<HTMLTextAreaElement | null>(null);
function chooseTarget(t: Thread) {
chosen.value = t;
step.value = 'note';
nextTick(() => noteInput.value?.focus());
}
function backToTargets() {
step.value = 'target';
chosen.value = null;
}
function confirm() {
if (!chosen.value) return;
emit('pick', chosen.value, note.value.trim());
}
return {query, step, chosen, note, noteInput, chooseTarget, backToTargets, confirm};
},
computed: {
targets(): Thread[] {
const q = this.query.trim().toLowerCase();
return this.threads.filter((t) => {
if (t.id === this.currentId) return false;
if (!q) return true;
return threadDisplayName(t).toLowerCase().includes(q);
});
},
},
methods: {
nameOf: threadDisplayName,
avatarOf: threadAvatarEmail,
},
});
</script>
<template>
<Teleport to="body">
<div class="fwd-overlay" @click.self="$emit('close')">
<div class="fwd-modal">
<!-- Step 1: pick target -->
<template v-if="step === 'target'">
<header class="fwd-modal-head">
<div class="fwd-modal-title">
<SvgIcon name="octicon-link" :size="16"/>
Forward {{ selectedCount }} to
</div>
<button type="button" class="head-icon" title="Cancel" @click="$emit('close')">
<SvgIcon name="octicon-x" :size="18"/>
</button>
</header>
<div class="fwd-modal-search">
<SvgIcon name="octicon-search" :size="16" class="fwd-search-icon"/>
<input v-model="query" type="text" class="fwd-search-input" placeholder="Filter conversations…">
</div>
<div class="fwd-modal-list">
<button
v-for="t in targets" :key="t.id"
type="button"
class="fwd-target"
@click="chooseTarget(t)"
>
<AvatarCircle :email="avatarOf(t)" :name="nameOf(t)" :is-group="t.isGroup" :size="32"/>
<div class="fwd-target-name">{{ nameOf(t) }}</div>
<SvgIcon name="octicon-chevron-right" :size="14" class="fwd-target-chev"/>
</button>
<div v-if="!targets.length" class="fwd-empty">No conversations to forward to</div>
</div>
</template>
<!-- Step 2: add a note -->
<template v-else>
<header class="fwd-modal-head">
<button type="button" class="head-icon" title="Back" @click="backToTargets">
<SvgIcon name="octicon-arrow-left" :size="18"/>
</button>
<div class="fwd-modal-title">
<SvgIcon name="octicon-link" :size="16"/>
Add a note
</div>
<button type="button" class="head-icon" title="Cancel" @click="$emit('close')">
<SvgIcon name="octicon-x" :size="18"/>
</button>
</header>
<div class="fwd-note-body">
<div class="fwd-note-target">
<AvatarCircle
v-if="chosen"
:email="avatarOf(chosen)"
:name="nameOf(chosen)"
:is-group="chosen.isGroup"
:size="24"
/>
<span class="fwd-note-target-name">{{ chosen ? nameOf(chosen) : '' }}</span>
</div>
<textarea
ref="noteInput"
v-model="note"
class="fwd-note-input"
placeholder="Add a message to accompany the forwarded content…"
rows="4"
/>
</div>
<div class="fwd-note-foot">
<button type="button" class="fwd-send" @click="confirm">
<SvgIcon name="octicon-paper-airplane" :size="16"/>
Forward
</button>
</div>
</template>
</div>
</div>
</Teleport>
</template>
<style scoped>
.fwd-overlay {
position: fixed;
inset: 0;
z-index: 1003;
background: var(--color-overlay-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.fwd-modal {
width: 100%;
max-width: 380px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--color-menu);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
overflow: hidden;
}
.fwd-modal-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
}
.fwd-modal-title {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: var(--font-weight-medium);
}
.head-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-light-2);
width: 28px;
height: 28px;
border-radius: var(--border-radius);
cursor: pointer;
padding: 0;
}
.head-icon:hover {
background: var(--color-hover);
color: var(--color-text);
}
.fwd-modal-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
}
.fwd-search-icon {
flex-shrink: 0;
color: var(--color-text-light-2);
}
.fwd-search-input {
flex: 1;
min-width: 0;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 6px 8px;
outline: none;
}
.fwd-search-input:focus {
border-color: var(--color-primary);
}
.fwd-modal-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 4px;
}
.fwd-target {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--border-radius);
text-align: left;
min-height: 48px;
}
.fwd-target:hover {
background: var(--color-hover);
}
.fwd-target-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fwd-target-chev {
color: var(--color-text-light-2);
flex-shrink: 0;
}
.fwd-empty {
padding: 24px 14px;
text-align: center;
font-size: 12px;
color: var(--color-text-light-2);
}
/* Note step */
.fwd-note-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
}
.fwd-note-target {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-light-2);
}
.fwd-note-target-name {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.fwd-note-input {
resize: none;
font-family: var(--fonts-regular);
font-size: 13px;
line-height: 1.4;
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
padding: 8px 10px;
outline: none;
}
.fwd-note-input:focus {
border-color: var(--color-primary);
}
.fwd-note-foot {
display: flex;
justify-content: flex-end;
padding: 10px 12px;
border-top: 0.5px solid var(--color-secondary-alpha-30);
}
.fwd-send {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: var(--color-primary);
color: var(--color-white);
font-size: 13px;
padding: 8px 14px;
border-radius: var(--border-radius-medium);
cursor: pointer;
min-height: 36px;
}
.fwd-send:hover {
opacity: 0.9;
}
</style>
@@ -0,0 +1,170 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import {type ForwardedContent, forwardedDepth, MAX_FORWARD_DEPTH} from '../types.ts';
import AvatarCircle from './AvatarCircle.vue';
// Recursive forwarded-content renderer. Order is reversed vs. a flat thread:
// the NESTED forwarded content renders on top, then this layer's body below it.
// Each layer indents by a left vertical hairline (so a forward-of-a-forward shows
// two nested hairlines, etc.). Up to MAX_FORWARD_DEPTH layers render fully; the
// first layer beyond the limit renders as a static, non-clickable mention noting
// how many nested layers are folded. It does NOT unfold or link anywhere.
export default defineComponent({
name: 'ForwardedBubble',
components: {SvgIcon, AvatarCircle},
props: {
content: {type: Object as PropType<ForwardedContent>, required: true},
depth: {type: Number, default: 1}, // 1-based: the outermost forward is depth 1
},
emits: ['jump'],
setup(props) {
// A layer beyond the visible depth limit renders as a single reference card.
const overLimit = props.depth > MAX_FORWARD_DEPTH;
const extraLayers = overLimit ? forwardedDepth(props.content) : 0;
return {overLimit, extraLayers, MAX_FORWARD_DEPTH};
},
computed: {
fromName(): string {
return this.content.from.name || this.content.from.email;
},
},
methods: {
formatTime(ts: number): string {
return new Date(ts).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
},
},
});
</script>
<template>
<div class="fwd" :class="['fwd-depth-' + depth]">
<!-- Static mention (non-clickable) for layers beyond the visible depth limit.
It just notes how many nested layers are folded; no author, no jump. -->
<div v-if="overLimit" class="fwd-ref">
<span class="fwd-ref-text">
{{ extraLayers }} more forwarded layer{{ extraLayers === 1 ? '' : 's' }}
</span>
</div>
<!-- Full render of an in-limit layer: nested forwarded content first, then body. -->
<template v-else>
<!-- Recurse into the nested forwarded content FIRST (it renders on top). -->
<ForwardedBubble
v-if="content.forwarded"
:content="content.forwarded"
:depth="depth + 1"
@jump="(c: ForwardedContent) => $emit('jump', c)"
/>
<!-- This layer's header + body (below the nested chain). -->
<div class="fwd-layer">
<div class="fwd-header">
<AvatarCircle :email="content.from.email" :name="fromName" :size="20"/>
<div class="fwd-meta">
<span class="fwd-from">Forwarded from {{ fromName }}</span>
<span class="fwd-subject" v-if="content.subject">{{ content.subject }}</span>
</div>
<span class="fwd-time">{{ formatTime(content.timestamp) }}</span>
</div>
<div class="fwd-body">{{ content.body }}</div>
</div>
</template>
</div>
</template>
<style scoped>
.fwd {
display: flex;
flex-direction: column;
gap: 8px;
/* Each nesting level adds a left vertical hairline via border, so a
forward-of-a-forward shows two nested lines, etc. */
border-left: 2px solid var(--color-secondary-alpha-30);
padding-left: 10px;
margin-left: 2px;
}
/* Deeper nesting renders the hairline slightly fainter to keep hierarchy readable. */
.fwd-depth-2 {
border-left-color: var(--color-secondary-alpha-30);
opacity: 0.96;
}
.fwd-layer {
display: flex;
flex-direction: column;
gap: 6px;
}
.fwd-header {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
}
.fwd-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.fwd-from {
font-size: 11px;
font-weight: var(--font-weight-medium);
color: var(--color-text-light-2);
}
.fwd-subject {
font-size: 11px;
color: var(--color-text-light-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fwd-time {
font-size: 10px;
color: var(--color-text-light-2);
flex-shrink: 0;
}
.fwd-body {
font-size: 13px;
line-height: 1.4;
color: var(--color-text);
padding: 8px 10px;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius);
word-break: break-word;
white-space: pre-wrap;
}
/* Static mention for layers beyond MAX_FORWARD_DEPTH. Non-clickable. */
.fwd-ref {
display: flex;
align-items: center;
width: 100%;
padding: 8px 10px;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius);
font-size: 12px;
color: var(--color-text-light-2);
text-align: left;
}
.fwd-ref-text {
font-style: italic;
}
</style>
@@ -0,0 +1,199 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, computed, ref} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Message} from '../types.ts';
import {sanitizeHtml} from '../sanitize.ts';
import {highlightHtml} from '../highlight.ts';
import AvatarCircle from './AvatarCircle.vue';
// Full-width HTML email card. Header bar with sender + timestamp + expand,
// sanitized body rendered with v-html, <pre> styled with mono font. When a
// search `query` is set, matches inside text nodes are highlighted.
export default defineComponent({
name: 'HtmlMailCard',
components: {SvgIcon, AvatarCircle},
props: {
message: {type: Object as PropType<Message>, required: true},
query: {type: String, default: ''},
},
setup(props) {
const expanded = ref(false);
const safeBody = computed(() => {
const clean = sanitizeHtml(props.message.body);
return highlightHtml(clean, props.query);
});
function formatTime(ts: number): string {
return new Date(ts).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
return {expanded, safeBody, formatTime};
},
});
</script>
<template>
<div class="html-card">
<div class="html-header">
<AvatarCircle
:email="message.sender.email"
:name="message.sender.name || message.sender.email"
:size="22"
/>
<span class="html-sender">{{ message.sender.name || message.sender.email }}</span>
<span class="html-time">{{ formatTime(message.timestamp) }}</span>
<button
type="button"
class="html-expand"
:title="expanded ? 'Collapse' : 'Expand'"
@click="expanded = !expanded"
>
<SvgIcon name="octicon-screen-full" :size="16"/>
</button>
</div>
<!-- v-html content is sanitized via sanitizeHtml() allowlist in safeBody -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="html-body" v-html="safeBody"/>
<Teleport to="body">
<div v-if="expanded" class="html-overlay" @click.self="expanded = false">
<div class="html-overlay-card">
<div class="html-header">
<AvatarCircle
:email="message.sender.email"
:name="message.sender.name || message.sender.email"
:size="22"
/>
<span class="html-sender">{{ message.sender.name || message.sender.email }}</span>
<span class="html-time">{{ formatTime(message.timestamp) }}</span>
<button
type="button"
class="html-expand"
title="Collapse"
@click="expanded = false"
>
<SvgIcon name="octicon-x" :size="16"/>
</button>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="html-body overlay-body" v-html="safeBody"/>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.html-card {
width: 100%;
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
overflow: hidden;
background: var(--color-body);
}
.html-header {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-secondary-bg);
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
padding: 8px 16px;
}
.html-sender {
font-size: 13px;
font-weight: var(--font-weight-medium);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.html-time {
font-size: 10px;
color: var(--color-text-light-2);
flex-shrink: 0;
}
.html-expand {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-light-2);
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius);
}
.html-expand:hover {
background: var(--color-hover);
color: var(--color-text);
}
.html-body {
padding: 12px 16px;
font-size: 13px;
line-height: 1.5;
}
.html-body :deep(pre) {
font-family: var(--fonts-monospace);
background: var(--color-secondary-bg);
padding: 8px;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: 12px;
}
.html-body :deep(h1),
.html-body :deep(h2),
.html-body :deep(h3) {
margin: 8px 0 4px;
}
.html-body :deep(table) {
border-collapse: collapse;
}
.html-body :deep(img) {
max-width: 100%;
height: auto;
}
/* full-screen overlay on mobile (and desktop expand) */
.html-overlay {
position: fixed;
inset: 0;
z-index: 1001;
background: var(--color-overlay-backdrop);
display: flex;
align-items: stretch;
justify-content: center;
padding: 0;
}
.html-overlay-card {
width: 100%;
max-width: 720px;
background: var(--color-body);
display: flex;
flex-direction: column;
overflow: hidden;
}
.overlay-body {
overflow-y: auto;
flex: 1;
}
</style>
@@ -0,0 +1,356 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, ref} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {Thread, Participant} from '../types.ts';
import AvatarCircle from './AvatarCircle.vue';
// Right sidebar showing group info: avatar, editable name, member list with
// add/remove. Emits `close`, `rename(newName)`, `add-member(email)`,
// `remove-member(email)`. On desktop it takes ~28% width sliding from the right;
// on mobile it fills the screen.
export default defineComponent({
name: 'MembersPanel',
components: {SvgIcon, AvatarCircle},
props: {
thread: {type: Object as PropType<Thread>, required: true},
},
emits: ['close', 'rename', 'add-member', 'remove-member'],
setup(props) {
const editingName = ref(false);
const nameValue = ref('');
const addingMember = ref(false);
const newEmail = ref('');
function startRename() {
nameValue.value = props.thread.subject;
editingName.value = true;
}
function saveRename() {
const name = nameValue.value.trim();
if (name) {
this.$emit('rename', name);
}
editingName.value = false;
}
function cancelRename() { editingName.value = false; }
function toggleAdd() {
addingMember.value = !addingMember.value;
newEmail.value = '';
}
function confirmAdd() {
const email = newEmail.value.trim();
if (email) {
this.$emit('add-member', email);
}
addingMember.value = false;
newEmail.value = '';
}
return {editingName, nameValue, addingMember, newEmail, startRename, saveRename, cancelRename, toggleAdd, confirmAdd};
},
methods: {
labelOf(p: Participant): string {
return p.name || p.email.slice(0, p.email.indexOf('@'));
},
},
});
</script>
<template>
<div class="members-panel">
<header class="members-head">
<button type="button" class="head-icon" title="Close" @click="$emit('close')">
<SvgIcon name="octicon-x" :size="18"/>
</button>
<div class="members-title">Group info</div>
</header>
<div class="members-body">
<!-- Group avatar + editable name -->
<div class="group-id">
<AvatarCircle :email="''" :name="thread.subject" :is-group="true" :size="56"/>
<div class="group-name-wrap">
<div v-if="!editingName" class="group-name-row">
<span class="group-name">{{ thread.subject }}</span>
<button type="button" class="head-icon sm" title="Rename" @click="startRename">
<SvgIcon name="octicon-tag" :size="14"/>
</button>
</div>
<div v-else class="group-rename">
<input v-model="nameValue" class="rename-inline" placeholder="Group name…" @keydown.enter="saveRename" @keydown.escape="cancelRename">
<button type="button" class="head-icon sm" title="Save" @click="saveRename">
<SvgIcon name="octicon-check" :size="14"/>
</button>
<button type="button" class="head-icon sm" title="Cancel" @click="cancelRename">
<SvgIcon name="octicon-x" :size="14"/>
</button>
</div>
</div>
</div>
<!-- Members section -->
<div class="members-section">
<div class="members-label">
<span>Members ({{ thread.participants.length }})</span>
<button type="button" class="head-icon sm" title="Add member" @click="toggleAdd">
<SvgIcon name="octicon-plus" :size="16"/>
</button>
</div>
<!-- Add member input -->
<div v-if="addingMember" class="add-member">
<input v-model="newEmail" class="add-input" placeholder="email@example.com" @keydown.enter="confirmAdd" @keydown.escape="toggleAdd">
<button type="button" class="head-icon sm" title="Add" @click="confirmAdd">
<SvgIcon name="octicon-check" :size="16"/>
</button>
</div>
<!-- Member list -->
<div class="members-list">
<div v-for="p in thread.participants" :key="p.email" class="member-item">
<AvatarCircle :email="p.email" :name="labelOf(p)" :size="32"/>
<div class="member-info">
<div class="member-name">
{{ labelOf(p) }}
<span v-if="p.self" class="member-self">you</span>
</div>
<div class="member-email">{{ p.email }}</div>
</div>
<button
v-if="!p.self"
type="button"
class="head-icon sm danger"
title="Remove member"
@click="$emit('remove-member', p.email)"
>
<SvgIcon name="octicon-x" :size="14"/>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.members-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: 10;
background: var(--color-body);
display: flex;
flex-direction: column;
border-left: 0.5px solid var(--color-secondary-alpha-30);
min-height: 0;
}
/* On mobile, take the full screen as an overlay */
@media (max-width: 639.98px) {
.members-panel {
width: 100%;
border-left: none;
}
}
.members-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
min-height: 52px;
}
.members-title {
flex: 1;
font-size: 14px;
font-weight: var(--font-weight-medium);
}
.head-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-light-2);
width: 28px;
height: 28px;
border-radius: var(--border-radius);
cursor: pointer;
padding: 0;
}
.head-icon.sm {
width: 24px;
height: 24px;
}
.head-icon:hover {
background: var(--color-hover);
color: var(--color-text);
}
.head-icon.danger:hover {
color: var(--color-danger);
}
.members-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 12px;
}
/* Group identity section */
.group-id {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.group-name-wrap {
width: 100%;
}
.group-name-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.group-name {
font-size: 15px;
font-weight: var(--font-weight-medium);
color: var(--color-text);
text-align: center;
}
.group-rename {
display: flex;
align-items: center;
gap: 4px;
}
.rename-inline {
flex: 1;
font-size: 14px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius);
padding: 5px 8px;
outline: none;
}
.rename-inline:focus {
border-color: var(--color-primary);
}
/* Members section */
.members-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.members-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: var(--font-weight-medium);
color: var(--color-text-light-2);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0 4px;
}
.add-member {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
}
.add-input {
flex: 1;
font-size: 13px;
font-family: var(--fonts-regular);
color: var(--color-text);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius);
padding: 6px 8px;
outline: none;
}
.add-input:focus {
border-color: var(--color-primary);
}
.members-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--border-radius);
min-height: 44px;
}
.member-item:hover {
background: var(--color-hover);
}
.member-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.member-name {
font-size: 13px;
font-weight: var(--font-weight-medium);
color: var(--color-text);
display: flex;
align-items: center;
gap: 6px;
}
.member-self {
font-size: 10px;
color: var(--color-primary);
background: var(--color-primary-alpha-10);
border-radius: var(--border-radius);
padding: 0 4px;
line-height: 16px;
}
.member-email {
font-size: 11px;
color: var(--color-text-light-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,760 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, computed, ref} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {ForwardedContent, Message} from '../types.ts';
import {highlightPlainText} from '../highlight.ts';
import {formatFileSize} from '../types.ts';
import AvatarCircle from './AvatarCircle.vue';
import ForwardedBubble from './ForwardedBubble.vue';
// Plain text message bubble. Others: left aligned with avatar + sender name.
// Self: right aligned, no name, blue-ish bubble. When the message has forwarded
// content, the forwarded panel renders ON TOP and the message body below it.
// A deeper-than-limit forwarded layer emits `jump` so the parent can open the
// origin thread at that message. When `selectable`, a checkbox appears.
export default defineComponent({
name: 'MessageBubble',
components: {SvgIcon, AvatarCircle, ForwardedBubble},
props: {
message: {type: Object as PropType<Message>, required: true},
query: {type: String, default: ''},
selectable: {type: Boolean, default: false},
selected: {type: Boolean, default: false},
playingTrackId: {type: String, default: ''},
},
emits: ['toggle-select', 'jump', 'play-audio'],
setup(props) {
const bubbleHtml = computed(() => highlightPlainText(props.message.body, props.query));
// Lightbox gallery: stores all image URLs for the lightbox
const galleryImages = computed(() => {
if (!props.message.attachments) return [];
return props.message.attachments.filter((a) => a.kind === 'image').map((a) => a.url);
});
// Media items for the gallery grid: ALL attachments
const mediaItems = computed(() => {
return props.message.attachments ?? [];
});
// Show the grid gallery when: 2+ images, or any non-image attachment exists.
// A single image-only message shows a large preview instead.
const shouldShowGallery = computed(() => {
const atts = props.message.attachments ?? [];
if (atts.length === 0) return false;
// Only single image or single video get natural-size preview;
// single audio and single file still render as square grid tiles.
if (atts.length === 1 && (atts[0].kind === 'image' || atts[0].kind === 'video')) return false;
return true;
});
const lightboxIndex = ref(-1);
const lightboxOpen = computed(() => lightboxIndex.value >= 0 && lightboxIndex.value < galleryImages.value.length);
const lightboxSrc = computed(() => lightboxOpen.value ? galleryImages.value[lightboxIndex.value] : null);
function openLightbox(url: string) {
const idx = galleryImages.value.indexOf(url);
lightboxIndex.value = idx >= 0 ? idx : 0;
}
function closeLightbox() { lightboxIndex.value = -1; }
function lightboxPrev() {
const n = galleryImages.value.length;
if (n > 0) lightboxIndex.value = (lightboxIndex.value - 1 + n) % n;
}
function lightboxNext() {
const n = galleryImages.value.length;
if (n > 0) lightboxIndex.value = (lightboxIndex.value + 1) % n;
}
return {bubbleHtml, galleryImages, mediaItems, shouldShowGallery, lightboxIndex, lightboxOpen, lightboxSrc, openLightbox, closeLightbox, lightboxPrev, lightboxNext};
},
methods: {
formatFileSize,
downloadFile(att: {url: string, name: string}) {
const a = document.createElement('a');
a.href = att.url;
a.download = att.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
},
},
});
</script>
<template>
<div class="msg-row" :class="{self: message.sender.self, selectable}">
<!-- Selection checkbox always renders at the leftmost edge of the row,
independent of the self/others alignment. It's a circular check. -->
<label v-if="selectable" class="msg-check">
<input type="checkbox" :checked="selected" @change="$emit('toggle-select')">
<span class="msg-check-box" :class="{checked: selected}">
<SvgIcon v-if="selected" name="octicon-check" :size="12" class="msg-check-icon"/>
</span>
</label>
<AvatarCircle
v-if="!message.sender.self"
:email="message.sender.email"
:name="message.sender.name || message.sender.email"
:size="22"
class="msg-avatar"
/>
<div class="msg-content">
<div v-if="!message.sender.self" class="msg-sender">
{{ message.sender.name || message.sender.email }}
</div>
<div class="msg-bubble-wrap" :class="{'atts-only': !message.body}">
<!-- Forwarded panel renders ON TOP of the body (reversed order). -->
<div v-if="message.forwarded || (message.quotes && message.quotes.length)" class="msg-forwarded">
<ForwardedBubble
v-if="message.forwarded"
:content="message.forwarded"
:depth="1"
@jump="(c: ForwardedContent) => $emit('jump', c)"
/>
<ForwardedBubble
v-for="(q, qi) in message.quotes ?? []" :key="qi"
:content="q"
:depth="1"
:class="{'fwd-sibling': qi > 0}"
@jump="(c: ForwardedContent) => $emit('jump', c)"
/>
</div>
<!-- Single image: large natural preview -->
<a
v-if="mediaItems.length === 1 && mediaItems[0].kind === 'image'"
href="#"
class="att-single-image"
@click.prevent="openLightbox(mediaItems[0].url)"
>
<img :src="mediaItems[0].url" :alt="mediaItems[0].name" loading="lazy">
</a>
<!-- Single video: natural-size inline player -->
<div
v-if="mediaItems.length === 1 && mediaItems[0].kind === 'video'"
class="att-single-video"
>
<video :src="mediaItems[0].url" controls preload="metadata"/>
<span class="att-gallery-label">{{ mediaItems[0].name }}</span>
</div>
<!-- All attachments as uniform square gallery tiles -->
<div
v-if="shouldShowGallery"
class="att-gallery"
:class="'att-gallery-' + Math.min(mediaItems.length, 3)"
>
<template v-for="att in message.attachments ?? []" :key="att.id">
<a v-if="att.kind === 'image'" href="#" class="att-gallery-item" @click.prevent="openLightbox(att.url)">
<img :src="att.url" :alt="att.name" loading="lazy">
</a>
<button v-else-if="att.kind === 'audio'" type="button" class="att-gallery-item att-gallery-audio"
:class="{'att-gallery-playing': playingTrackId === att.id}" @click.stop="$emit('play-audio', att)">
<span v-if="playingTrackId === att.id" class="att-audio-pause-icon"/>
<span v-else class="att-audio-play-icon"/>
<span class="att-gallery-label att-gallery-audio-label">{{ att.name }}</span>
</button>
<div v-else-if="att.kind === 'video'" class="att-gallery-item att-gallery-video">
<video :src="att.url" controls preload="metadata"/>
<span class="att-gallery-label">{{ att.name }}</span>
</div>
<button v-else type="button" class="att-gallery-item att-gallery-file" @click.stop="downloadFile(att)">
<SvgIcon name="octicon-file" :size="40"/>
<span class="att-gallery-file-info">
<span class="att-gallery-file-name">{{ att.name }}</span>
<span class="att-gallery-file-size">{{ formatFileSize(att.size) }}</span>
</span>
</button>
</template>
</div>
<!-- bubbleHtml is escaped+highlighted output, safe for v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="message.body" class="msg-bubble" :class="{searching: !!query}" v-html="bubbleHtml"/>
</div>
<div class="msg-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<!-- Image lightbox gallery with navigation -->
<Teleport to="body">
<div v-if="lightboxOpen" class="att-lightbox" @click="closeLightbox" @keydown.escape="closeLightbox">
<button type="button" class="att-lightbox-close" title="Close (Esc)" @click.stop="closeLightbox">
<SvgIcon name="octicon-x" :size="24"/>
</button>
<img v-if="lightboxSrc" :src="lightboxSrc" class="att-lightbox-img" alt="" @click.stop>
<!-- Navigation arrows (only when multiple images) -->
<button
v-if="galleryImages.length > 1"
type="button"
class="att-lightbox-nav att-lightbox-prev"
title="Previous (←)"
@click.stop="lightboxPrev"
>
<SvgIcon name="octicon-chevron-left" :size="28"/>
</button>
<button
v-if="galleryImages.length > 1"
type="button"
class="att-lightbox-nav att-lightbox-next"
title="Next (→)"
@click.stop="lightboxNext"
>
<SvgIcon name="octicon-chevron-right" :size="28"/>
</button>
<!-- Counter -->
<span v-if="galleryImages.length > 1" class="att-lightbox-count" @click.stop>
{{ lightboxIndex + 1 }} / {{ galleryImages.length }}
</span>
</div>
</Teleport>
</template>
<style scoped>
.msg-row {
display: flex;
align-items: flex-start;
gap: 8px;
max-width: 100%;
}
/* Self-messages align content to the right via margin-left:auto rather than
row-reverse. This keeps the checkbox (first child) anchored at the far-left
edge for BOTH incoming and outgoing, on the same vertical line. */
.msg-row.self .msg-content {
margin-left: auto;
}
.msg-row.selectable {
cursor: pointer;
}
.msg-avatar {
margin-top: 2px;
}
.msg-content {
display: flex;
flex-direction: column;
max-width: min(75%, 520px);
}
/* Attachments can be wider than the text bubble */
.msg-content .msg-attachments {
width: auto;
max-width: 100%;
}
.msg-row.self .msg-content {
align-items: flex-end;
}
.msg-sender {
font-size: 11px;
color: var(--color-text-light-2);
margin: 0 0 2px 2px;
}
.msg-bubble-wrap {
display: flex;
flex-direction: column;
gap: 4px;
overflow: visible;
width: 100%;
}
.msg-bubble {
font-size: 13px;
line-height: 1.4;
padding: 7px 11px;
border-radius: var(--border-radius-medium);
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-top: none;
border-bottom-left-radius: 3px;
word-break: break-word;
white-space: pre-wrap;
}
.msg-row.self .msg-bubble {
background: hsl(214, 70%, 93%);
color: hsl(214, 70%, 30%);
border: 0.5px solid hsl(214, 70%, 88%);
border-top: none;
border-bottom-left-radius: var(--border-radius-medium);
border-bottom-right-radius: 3px;
}
/* When a message carries forwarded content, the forwarded panel sits on top and
the bubble drops its TOP radius so it visually attaches below the panel. */
.msg-forwarded {
border: 0.5px solid var(--color-secondary-alpha-30);
border-bottom: none;
border-top-left-radius: var(--border-radius-medium);
border-top-right-radius: var(--border-radius-medium);
background: var(--color-body);
padding: 8px 10px;
}
.msg-row.self .msg-forwarded {
border-color: hsl(214, 70%, 88%);
}
/* When multiple forwarded messages are grouped as siblings, a hairline separates
them so each quote reads as its own block at the same nesting level. */
.msg-forwarded :deep(.fwd-sibling) {
margin-top: 6px;
padding-top: 6px;
border-top: 0.5px solid var(--color-secondary-alpha-30);
}
.msg-time {
font-size: 10px;
color: var(--color-text-light-2);
margin: 2px 2px 0;
}
/* Selection checkbox: visually a custom box since the native input is hidden.
It centers vertically against the bubble content via align-self on the row. */
.msg-check {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
cursor: pointer;
flex-shrink: 0;
width: 22px;
margin-left: 4px; /* small breathing room from the left edge of the messages area */
}
.msg-check input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.msg-check-box {
width: 20px;
height: 20px;
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-full);
background: var(--color-secondary-bg);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-white);
}
.msg-check-box.checked {
background: var(--color-primary);
border-color: var(--color-primary);
}
.msg-check-icon {
color: var(--color-white);
}
/* Attachments */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 2px;
}
.att-item {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 100%;
}
/* When message is attachment-only (no body text), remove border constraints */
.msg-bubble-wrap.atts-only .msg-attachments {
padding: 0;
}
.att-image img {
max-width: 280px;
max-height: 280px;
min-width: 80px;
min-height: 80px;
border-radius: var(--border-radius-medium);
border: 0.5px solid var(--color-secondary-alpha-30);
cursor: pointer;
object-fit: contain;
display: block;
background: var(--color-secondary-bg);
}
.att-video video {
max-width: 280px;
max-height: 200px;
border-radius: var(--border-radius-medium);
border: 0.5px solid var(--color-secondary-alpha-30);
}
/* Audio tile in gallery grid — same square shape as image tiles */
.att-gallery-audio {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30) !important;
cursor: pointer;
font-family: inherit;
position: relative;
width: 100%;
height: 100%;
min-height: 0;
aspect-ratio: 1 !important;
}
.att-gallery-audio:hover {
opacity: 0.85;
}
/* Highlight the currently playing audio tile */
.att-gallery-playing {
background: var(--color-primary-alpha-10) !important;
border-color: var(--color-primary) !important;
}
/* Audio icon: grey, centered */
/* CSS play triangle (matches player) */
.att-audio-play-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 16px 0 16px 26px;
border-color: transparent transparent transparent var(--color-text-light-2);
margin-left: 6px;
}
/* CSS pause bars (two fat vertical lines) */
.att-audio-pause-icon {
display: flex;
gap: 5px;
}
.att-audio-pause-icon::before,
.att-audio-pause-icon::after {
content: '';
width: 6px;
height: 24px;
background: var(--color-primary);
border-radius: 2px;
}
/* Filename: bottom-right with padding, slightly bigger */
.att-gallery-audio-label {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 11px;
color: var(--color-text-light-2);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 8px);
line-height: 1.2;
}
.att-gallery-label {
font-size: 10px;
color: var(--color-text);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
line-height: 1.2;
}
.att-gallery-sub {
font-size: 9px;
color: var(--color-text-light-2);
}
/* Video tile: fills the square with the video player */
.att-gallery-video {
background: var(--color-body);
padding: 0;
overflow: hidden;
}
.att-gallery-video video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
/* File tile: centered icon + filename */
.att-gallery-file {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30) !important;
cursor: pointer;
font-family: inherit;
position: relative;
width: 100%;
height: 100%;
min-height: 0;
aspect-ratio: 1 !important;
}
.att-gallery-file:hover {
opacity: 0.85;
}
.att-gallery-file svg {
flex-shrink: 0;
color: var(--color-text-light-2);
}
.att-gallery-file-info {
position: absolute;
bottom: 4px;
left: 4px;
right: 4px;
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
line-height: 1.2;
}
.att-gallery-file-name {
flex: 1;
min-width: 0;
color: var(--color-text-light-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-gallery-file-size {
flex-shrink: 0;
color: var(--color-text-light-3);
}
/* Single image: large non-square preview */
.att-single-image {
aspect-ratio: auto;
display: block;
}
.att-single-image img {
max-width: 280px;
max-height: 280px;
min-width: 80px;
min-height: 80px;
object-fit: contain;
background: var(--color-secondary-bg);
}
/* Single video: natural-size player */
.att-single-video video {
max-width: 280px;
max-height: 240px;
border-radius: var(--border-radius-medium);
border: 0.5px solid var(--color-secondary-alpha-30);
}
.att-single-video .att-gallery-label {
display: block;
font-size: 10px;
color: var(--color-text-light-2);
text-align: center;
margin-top: 4px;
}
.att-audio-name {
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-label {
font-size: 10px;
color: var(--color-text-light-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-file {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--color-secondary-bg);
border: 0.5px solid var(--color-secondary-alpha-30);
border-radius: var(--border-radius-medium);
text-decoration: none;
color: var(--color-text);
min-height: 40px;
}
.att-file:hover {
background: var(--color-hover);
}
.att-file-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.att-file-name {
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 160px;
}
.att-file-size {
font-size: 10px;
color: var(--color-text-light-2);
}
/* Image gallery grid — multiple images in a compact grid */
.att-gallery {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.att-gallery-item {
display: block;
overflow: hidden;
border-radius: var(--border-radius-medium);
border: 0.5px solid var(--color-secondary-alpha-30);
cursor: pointer;
width: 96px;
height: 96px;
flex-shrink: 0;
}
.att-gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Gallery with 1 image: no aspect ratio constraint, show full size */
.att-gallery-1 .att-gallery-item {
aspect-ratio: auto;
}
.att-gallery-1 .att-gallery-item img {
max-width: 280px;
max-height: 280px;
min-width: 80px;
min-height: 80px;
object-fit: contain;
background: var(--color-secondary-bg);
}
/* Image lightbox — full-screen overlay with the image centered and navigation */
.att-lightbox {
position: fixed;
inset: 0;
z-index: 1004;
background: var(--color-overlay-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.att-lightbox-img {
max-width: 90vw;
max-height: 90vh;
border-radius: var(--border-radius-medium);
object-fit: contain;
}
.att-lightbox-close {
position: absolute;
top: 16px;
right: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: var(--color-overlay-backdrop);
color: #fff;
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
cursor: pointer;
padding: 0;
}
.att-lightbox-close:hover {
background: var(--color-overlay-backdrop);
}
.att-lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: var(--color-overlay-backdrop);
color: #fff;
width: 48px;
height: 48px;
border-radius: var(--border-radius-full);
cursor: pointer;
padding: 0;
}
.att-lightbox-nav:hover {
opacity: 0.85;
}
.att-lightbox-prev {
left: 16px;
}
.att-lightbox-next {
right: 16px;
}
.att-lightbox-count {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
font-size: 13px;
color: #fff;
background: var(--color-overlay-backdrop);
padding: 4px 12px;
border-radius: var(--border-radius-full);
}
</style>
@@ -0,0 +1,429 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
<script lang="ts">
import {defineComponent, type PropType, computed, ref, onMounted, onBeforeUnmount} from 'vue';
import {SvgIcon} from '../../../svg.ts';
import type {BlockEntry, BlockScope, Thread} from '../types.ts';
import {threadDisplayName, threadAvatarEmail} from '../threadTitle.ts';
import AvatarCircle from './AvatarCircle.vue';
// Left panel thread list with pinned + messages sections, search filtering,
// and a right-click / long-press context menu with an expandable Block group.
export default defineComponent({
name: 'ThreadList',
components: {SvgIcon, AvatarCircle},
props: {
threads: {type: Array as PropType<Thread[]>, required: true},
activeId: {type: String, default: ''},
query: {type: String, default: ''},
blocked: {type: Array as PropType<BlockEntry[]>, default: () => []},
isThreadBlocked: {type: Function as PropType<(t: Thread) => boolean>, required: true},
blockScopeOf: {type: Function as PropType<(t: Thread) => BlockScope | null>, required: true},
},
emits: ['select', 'toggle-pin', 'mark-unread', 'delete', 'block', 'unblock'],
setup(props, {emit}) {
// Name/email resolution is centralised in threadTitle.ts for the backend merge.
const threadName = threadDisplayName;
const threadEmail = threadAvatarEmail;
function lastMessage(t: Thread) {
for (let i = t.messages.length - 1; i >= 0; i--) {
const m = t.messages[i];
if (m.kind === 'text' || m.kind === 'html') return m;
}
return t.messages[t.messages.length - 1];
}
function lastPreview(t: Thread): string {
const m = lastMessage(t);
if (!m) return '';
if (m.kind === 'html') return 'HTML message';
return m.body;
}
function lastTimestamp(t: Thread): number {
const m = lastMessage(t);
return m ? m.timestamp : 0;
}
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
const min = 60_000, hour = 60 * min, day = 24 * hour;
if (diff < min) return 'now';
if (diff < hour) return Math.floor(diff / min) + 'm';
if (diff < day) return Math.floor(diff / hour) + 'h';
if (diff < 7 * day) return Math.floor(diff / day) + 'd';
return new Date(ts).toLocaleDateString([], {month: 'short', day: 'numeric'});
}
const filtered = computed<Thread[]>(() => {
const q = props.query.trim().toLowerCase();
if (!q) return props.threads;
return props.threads.filter((t) => {
return threadName(t).toLowerCase().includes(q) || lastPreview(t).toLowerCase().includes(q);
});
});
const pinned = computed(() => filtered.value.filter((t) => t.pinned));
const others = computed(() => filtered.value.filter((t) => !t.pinned));
// Context menu. Delete sits at the top; Block is an expandable toggle whose
// options render below it when open.
const menu = ref<{threadId: string, x: number, y: number} | null>(null);
const submenuOpen = ref(false);
let longPressTimer: number | undefined;
// Guard timestamp: some browsers (e.g. Firefox) fire a `click` on right-click;
// ignore clicks within this window so the menu doesn't close instantly.
let menuOpenedAt = 0;
function openMenu(t: Thread, x: number, y: number) {
// clamp so the menu never renders off the right/bottom edge, where it
// would appear "missing" to the user.
const w = window.innerWidth;
const h = window.innerHeight;
const maxX = Math.max(0, w - 180);
const maxY = Math.max(0, h - 320);
menu.value = {threadId: t.id, x: Math.min(x, maxX), y: Math.min(y, maxY)};
submenuOpen.value = false;
menuOpenedAt = Date.now();
}
function onContextMenu(e: MouseEvent, t: Thread) {
e.preventDefault();
openMenu(t, e.clientX, e.clientY);
}
function onTouchStart(e: TouchEvent, t: Thread) {
const touch = e.touches[0];
longPressTimer = window.setTimeout(() => {
openMenu(t, touch.clientX, touch.clientY);
}, 500);
}
function clearLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = undefined;
}
}
function closeMenu() {
menu.value = null;
submenuOpen.value = false;
}
function menuThread(): Thread | undefined {
return props.threads.find((t) => t.id === menu.value?.threadId);
}
function pinFromMenu() {
if (menu.value) emit('toggle-pin', menu.value.threadId);
closeMenu();
}
function unreadFromMenu() {
if (menu.value) emit('mark-unread', menu.value.threadId);
closeMenu();
}
function deleteFromMenu() {
if (menu.value) emit('delete', menu.value.threadId);
closeMenu();
}
function toggleSubmenu() {
submenuOpen.value = !submenuOpen.value;
}
function blockFromMenu(scope: BlockScope, alsoDelete: boolean) {
if (menu.value) emit('block', menu.value.threadId, scope, alsoDelete);
closeMenu();
}
function unblockFromMenu() {
if (menu.value) emit('unblock', menu.value.threadId);
closeMenu();
}
function onWindowClickClose(e: MouseEvent) {
// ignore the synthesized click that some browsers fire right after contextmenu
if (Date.now() - menuOpenedAt < 350) return;
// ignore clicks inside the menu itself
if ((e.target as HTMLElement)?.closest('.ctx-menu')) return;
closeMenu();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (submenuOpen.value) submenuOpen.value = false;
else closeMenu();
}
}
onMounted(() => {
window.addEventListener('click', onWindowClickClose);
window.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('click', onWindowClickClose);
window.removeEventListener('keydown', onKeydown);
clearLongPress();
});
return {
filtered, pinned, others,
threadName, threadEmail, lastPreview, lastTimestamp, formatRelative,
menu, submenuOpen, onContextMenu, onTouchStart, clearLongPress, closeMenu, menuThread,
pinFromMenu, unreadFromMenu, deleteFromMenu, toggleSubmenu, blockFromMenu, unblockFromMenu,
};
},
});
</script>
<template>
<div class="thread-list">
<!-- pinned section -->
<template v-if="pinned.length">
<div class="list-section-label">
<SvgIcon name="octicon-pin" :size="12"/>
Pinned
</div>
<div
v-for="t in pinned" :key="t.id"
class="thread-item"
:class="{active: t.id === activeId, unread: t.unread, blocked: isThreadBlocked(t)}"
@click="$emit('select', t.id)"
@contextmenu="onContextMenu($event, t)"
@touchstart.passive="onTouchStart($event, t)"
@touchend="clearLongPress"
@touchmove="clearLongPress"
>
<AvatarCircle :email="threadEmail(t)" :name="threadName(t)" :is-group="t.isGroup" :size="36"/>
<div class="ti-center">
<div class="ti-name">
{{ threadName(t) }}
<span v-if="isThreadBlocked(t)" class="ti-blocked-tag">
{{ blockScopeOf(t) === 'domain' ? 'Blocked (domain)' : 'Blocked' }}
</span>
</div>
<div class="ti-preview">{{ lastPreview(t) }}</div>
</div>
<div class="ti-right">
<div class="ti-time">{{ formatRelative(lastTimestamp(t)) }}</div>
<SvgIcon v-if="t.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
<span v-else-if="t.unread" class="ti-dot"/>
</div>
</div>
</template>
<div class="list-section-label">Messages</div>
<div
v-for="t in others" :key="t.id"
class="thread-item"
:class="{active: t.id === activeId, unread: t.unread, blocked: isThreadBlocked(t)}"
@click="$emit('select', t.id)"
@contextmenu="onContextMenu($event, t)"
@touchstart.passive="onTouchStart($event, t)"
@touchend="clearLongPress"
@touchmove="clearLongPress"
>
<AvatarCircle :email="threadEmail(t)" :name="threadName(t)" :is-group="t.isGroup" :size="36"/>
<div class="ti-center">
<div class="ti-name">
{{ threadName(t) }}
<span v-if="isThreadBlocked(t)" class="ti-blocked-tag">
{{ blockScopeOf(t) === 'domain' ? 'Blocked (domain)' : 'Blocked' }}
</span>
</div>
<div class="ti-preview">{{ lastPreview(t) }}</div>
</div>
<div class="ti-right">
<div class="ti-time">{{ formatRelative(lastTimestamp(t)) }}</div>
<SvgIcon v-if="t.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
<span v-else-if="t.unread" class="ti-dot"/>
</div>
</div>
<div v-if="!filtered.length" class="empty">No conversations found</div>
<!-- context menu (teleported; styles live unscoped in mail.css).
Order: Pin, Mark unread, Delete, then an expandable Block toggle. -->
<Teleport to="body">
<div
v-if="menu"
class="ctx-menu"
:style="{left: menu.x + 'px', top: menu.y + 'px'}"
@click.stop
>
<button type="button" class="ctx-item" @click="pinFromMenu">
<SvgIcon name="octicon-pin" :size="14"/>
{{ menuThread()?.pinned ? 'Unpin' : 'Pin' }}
</button>
<button type="button" class="ctx-item" @click="unreadFromMenu">
<SvgIcon name="octicon-dot-fill" :size="14"/>
Mark unread
</button>
<button type="button" class="ctx-item danger" @click="deleteFromMenu">
<SvgIcon name="octicon-trash" :size="14"/>
Delete
</button>
<!-- Block: expandable toggle (DMs only). Renders its options below. -->
<button
v-if="menuThread() && !menuThread()!.isGroup && !isThreadBlocked(menuThread()!)"
type="button"
class="ctx-item danger ctx-toggle"
:class="{expanded: submenuOpen}"
@click="toggleSubmenu"
>
<SvgIcon name="octicon-blocked" :size="14"/>
Block
<SvgIcon
name="octicon-chevron-down"
:size="12"
class="ctx-chev"
:class="{up: submenuOpen}"
/>
</button>
<button
v-else-if="menuThread() && !menuThread()!.isGroup && isThreadBlocked(menuThread()!)"
type="button" class="ctx-item danger" @click="unblockFromMenu"
>
<SvgIcon name="octicon-blocked" :size="14"/>
Unblock sender
</button>
<!-- Expandable Block options (no icons; text flush left) -->
<div v-if="submenuOpen && menuThread() && !menuThread()!.isGroup" class="ctx-expand">
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', false)">
Block sender
</button>
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', true)">
Block & delete
</button>
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('domain', true)">
Block domain
</button>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.thread-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.list-section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: var(--font-weight-medium);
color: var(--color-text-light-2);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 14px 4px;
}
.thread-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
cursor: pointer;
min-height: 56px; /* >= 44px touch target */
}
.thread-item:hover {
background: var(--color-hover);
}
.thread-item.active {
background: var(--color-secondary-bg);
}
.thread-item.blocked {
opacity: var(--opacity-disabled);
}
.ti-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ti-name {
font-size: 13px;
font-weight: var(--font-weight-normal);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.thread-item.unread .ti-name {
font-weight: var(--font-weight-medium);
}
.ti-blocked-tag {
font-size: 10px;
font-weight: var(--font-weight-medium);
color: var(--color-danger);
background: var(--color-red-badge-bg);
border: 0.5px solid var(--color-red-badge);
border-radius: var(--border-radius);
padding: 0 4px;
line-height: 16px;
flex-shrink: 0;
}
.ti-preview {
font-size: 11px;
color: var(--color-text-light-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ti-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.ti-time {
font-size: 10px;
color: var(--color-text-light-2);
}
.ti-pin {
color: var(--color-text-light-2);
}
.ti-dot {
width: 8px;
height: 8px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
}
.empty {
padding: 24px 14px;
text-align: center;
font-size: 12px;
color: var(--color-text-light-2);
}
</style>
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
// Highlighting helpers for the in-thread search. All user-controlled text is
// HTML-escaped before being wrapped, so the produced markup is safe to v-html.
// Built from char codes so the entity strings are never decoded by tooling.
const AMP = String.fromCharCode(38);
const ENT_AMP = `${AMP}amp;`;
const ENT_LT = `${AMP}lt;`;
const ENT_GT = `${AMP}gt;`;
const ENT_QUOT = `${AMP}quot;`;
const ENT_SQUOT = `${AMP}#39;`;
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case '&': return ENT_AMP;
case '<': return ENT_LT;
case '>': return ENT_GT;
case '"': return ENT_QUOT;
default: return ENT_SQUOT; // '
}
});
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Wraps case-insensitive occurrences of `query` in <mark> inside the plain
// text `text`. Returns HTML safe for v-html. Empty query returns escaped text.
export function highlightPlainText(text: string, query: string): string {
const escaped = escapeHtml(text);
const q = query.trim();
if (!q) return escaped;
const pattern = new RegExp(`(${escapeRegExp(escapeHtml(q))})`, 'gi');
// Trusted wrapper literal around already-escaped content.
// eslint-disable-next-line gitea/unescaped-html-literal
return escaped.replace(pattern, '<mark class="search-hit">$1</mark>');
}
// Returns true if `text` contains `query` (case-insensitive). Used to build
// the match list without constructing HTML.
export function textMatches(text: string, query: string): boolean {
const q = query.trim();
if (!q) return false;
return text.toLowerCase().includes(q.toLowerCase());
}
// Visible text of an HTML fragment, used for match counting and searching.
export function htmlTextContent(html: string): string {
// Wrapping root is a trusted fixed literal.
// eslint-disable-next-line gitea/unescaped-html-literal
const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, 'text/html');
return doc.querySelector<HTMLElement>('#root')!.textContent ?? '';
}
// Wraps occurrences of `query` inside the text nodes of an HTML string,
// leaving tags/attributes untouched. Returns HTML safe for v-html. The input
// is assumed already-sanized; this only mutates TEXT_NODE children.
export function highlightHtml(html: string, query: string): string {
const q = query.trim();
if (!q) return html;
// Wrapping root is a trusted fixed literal.
// eslint-disable-next-line gitea/unescaped-html-literal
const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, 'text/html');
const root = doc.querySelector<HTMLElement>('#root')!;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const targets: Text[] = [];
let node = walker.nextNode();
while (node) {
targets.push(node as Text);
node = walker.nextNode();
}
const pattern = new RegExp(escapeRegExp(q), 'gi');
for (const textNode of targets) {
const value = textNode.nodeValue ?? '';
if (!pattern.test(value)) continue;
pattern.lastIndex = 0;
const frag = splitAndMark(doc, value, pattern);
textNode.replaceWith(frag);
}
return root.innerHTML;
}
function splitAndMark(doc: Document, value: string, pattern: RegExp): DocumentFragment {
const frag = doc.createDocumentFragment();
let last = 0;
let match: RegExpExecArray | null;
pattern.lastIndex = 0;
while ((match = pattern.exec(value)) !== null) {
if (match.index > last) frag.append(doc.createTextNode(value.slice(last, match.index)));
const mark = doc.createElement('mark');
mark.className = 'search-hit';
mark.textContent = match[0];
frag.append(mark);
last = match.index + match[0].length;
if (match[0].length === 0) pattern.lastIndex++; // avoid zero-width loop
}
if (last < value.length) frag.append(doc.createTextNode(value.slice(last)));
return frag;
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
import {createApp} from 'vue';
import MailApp from './MailApp.vue';
// Mounts the M8SH Mail Vue app into the #mail-app element rendered by mail.tmpl.
export function initMailApp(): void {
const el = document.querySelector('#mail-app');
if (el) {
createApp(MailApp).mount(el);
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
// Pure-TypeScript MD5 implementation (RFC 1321) returning a lowercase hex digest.
// No external library, used only for building Gravatar URLs.
function safeAdd(x: number, y: number): number {
const lsw = (x & 0xffff) + (y & 0xffff);
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xffff);
}
function rol(num: number, cnt: number): number {
return (num << cnt) | (num >>> (32 - cnt));
}
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
return safeAdd(rol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function coreMD5(x: number[], len: number): number[] {
x[len >> 5] |= 0x80 << (len % 32);
x[(((len + 64) >>> 9) << 4) + 14] = len;
let a = 1732584193;
let b = -271733879;
let c = -1732584194;
let d = 271733878;
for (let i = 0; i < x.length; i += 16) {
const olda = a;
const oldb = b;
const oldc = c;
const oldd = d;
a = ff(a, b, c, d, x[i], 7, -680876936);
d = ff(d, a, b, c, x[i + 1], 12, -389564586);
c = ff(c, d, a, b, x[i + 2], 17, 606105819);
b = ff(b, c, d, a, x[i + 3], 22, -1044525330);
a = ff(a, b, c, d, x[i + 4], 7, -176418897);
d = ff(d, a, b, c, x[i + 5], 12, 1200080426);
c = ff(c, d, a, b, x[i + 6], 17, -1473231341);
b = ff(b, c, d, a, x[i + 7], 22, -45705983);
a = ff(a, b, c, d, x[i + 8], 7, 1770035416);
d = ff(d, a, b, c, x[i + 9], 12, -1958414417);
c = ff(c, d, a, b, x[i + 10], 17, -42063);
b = ff(b, c, d, a, x[i + 11], 22, -1990404162);
a = ff(a, b, c, d, x[i + 12], 7, 1804603682);
d = ff(d, a, b, c, x[i + 13], 12, -40341101);
c = ff(c, d, a, b, x[i + 14], 17, -1502002290);
b = ff(b, c, d, a, x[i + 15], 22, 1236535329);
a = gg(a, b, c, d, x[i + 1], 5, -165796510);
d = gg(d, a, b, c, x[i + 6], 9, -1069501632);
c = gg(c, d, a, b, x[i + 11], 14, 643717713);
b = gg(b, c, d, a, x[i], 20, -373897302);
a = gg(a, b, c, d, x[i + 5], 5, -701558691);
d = gg(d, a, b, c, x[i + 10], 9, 38016083);
c = gg(c, d, a, b, x[i + 15], 14, -660478335);
b = gg(b, c, d, a, x[i + 4], 20, -405537848);
a = gg(a, b, c, d, x[i + 9], 5, 568446438);
d = gg(d, a, b, c, x[i + 14], 9, -1019803690);
c = gg(c, d, a, b, x[i + 3], 14, -187363961);
b = gg(b, c, d, a, x[i + 8], 20, 1163531501);
a = gg(a, b, c, d, x[i + 13], 5, -1444681467);
d = gg(d, a, b, c, x[i + 2], 9, -51403784);
c = gg(c, d, a, b, x[i + 7], 14, 1735328473);
b = gg(b, c, d, a, x[i + 12], 20, -1926607734);
a = hh(a, b, c, d, x[i + 5], 4, -378558);
d = hh(d, a, b, c, x[i + 8], 11, -2022574463);
c = hh(c, d, a, b, x[i + 11], 16, 1839030562);
b = hh(b, c, d, a, x[i + 14], 23, -35309556);
a = hh(a, b, c, d, x[i + 1], 4, -1530992060);
d = hh(d, a, b, c, x[i + 4], 11, 1272893353);
c = hh(c, d, a, b, x[i + 7], 16, -155497632);
b = hh(b, c, d, a, x[i + 10], 23, -1094730640);
a = hh(a, b, c, d, x[i + 13], 4, 681279174);
d = hh(d, a, b, c, x[i], 11, -358537222);
c = hh(c, d, a, b, x[i + 3], 16, -722521979);
b = hh(b, c, d, a, x[i + 6], 23, 76029189);
a = hh(a, b, c, d, x[i + 9], 4, -640364487);
d = hh(d, a, b, c, x[i + 12], 11, -421815835);
c = hh(c, d, a, b, x[i + 15], 16, 530742520);
b = hh(b, c, d, a, x[i + 2], 23, -995338651);
a = ii(a, b, c, d, x[i], 6, -198630844);
d = ii(d, a, b, c, x[i + 7], 10, 1126891415);
c = ii(c, d, a, b, x[i + 14], 15, -1416354905);
b = ii(b, c, d, a, x[i + 5], 21, -57434055);
a = ii(a, b, c, d, x[i + 12], 6, 1700485571);
d = ii(d, a, b, c, x[i + 3], 10, -1894986606);
c = ii(c, d, a, b, x[i + 10], 15, -1051523);
b = ii(b, c, d, a, x[i + 1], 21, -2054922799);
a = ii(a, b, c, d, x[i + 8], 6, 1873313359);
d = ii(d, a, b, c, x[i + 15], 10, -30611744);
c = ii(c, d, a, b, x[i + 6], 15, -1560198380);
b = ii(b, c, d, a, x[i + 13], 21, 1309151649);
a = ii(a, b, c, d, x[i + 4], 6, -145523070);
d = ii(d, a, b, c, x[i + 11], 10, -1120210379);
c = ii(c, d, a, b, x[i + 2], 15, 718787259);
b = ii(b, c, d, a, x[i + 9], 21, -343485551);
a = safeAdd(a, olda);
b = safeAdd(b, oldb);
c = safeAdd(c, oldc);
d = safeAdd(d, oldd);
}
return [a, b, c, d];
}
// Pack the 4 state words into a binary-style string (16 chars, one per byte, big-endian per word).
function binl2rstr(input: number[]): string {
let output = '';
for (let i = 0; i < input.length * 32; i += 8) {
output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff);
}
return output;
}
function bytes2binl(bytes: Uint8Array): number[] {
const output: number[] = [];
for (let i = 0; i < bytes.length * 8; i += 8) {
output[i >> 5] |= (bytes[i / 8] & 0xff) << (i % 32);
}
return output;
}
function digestBytes(bytes: Uint8Array): string {
return binl2rstr(coreMD5(bytes2binl(bytes), bytes.length * 8));
}
function utf8Bytes(input: string): Uint8Array {
return new TextEncoder().encode(input);
}
function rstr2hex(input: string): string {
const hexTab = '0123456789abcdef';
let output = '';
for (let i = 0; i < input.length; i++) {
const x = input.charCodeAt(i);
output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f);
}
return output;
}
// Public entry point: MD5 hex digest of a UTF-8 encoded string.
export function md5(s: string): string {
return rstr2hex(digestBytes(utf8Bytes(s)));
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
// Minimal HTML allowlist sanitizer for the mock HTML email card.
// Strips <script>, <iframe>, on* attributes and a small denylist of tags.
// This is NOT a full sanitizer, only a "simple allowlist" as requested for the mock UI.
const ALLOWED_TAGS = new Set([
'a', 'b', 'i', 'u', 's', 'em', 'strong', 'p', 'br', 'hr',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
'img', 'hr',
]);
export function sanitizeHtml(input: string): string {
// Wrapping root container is a fixed trusted literal, not user input.
// eslint-disable-next-line gitea/unescaped-html-literal
const doc = new DOMParser().parseFromString(`<div id="root">${input}</div>`, 'text/html');
const root = doc.querySelector<HTMLElement>('#root')!;
cleanNode(root);
return root.innerHTML;
}
function cleanNode(node: HTMLElement): void {
// iterate over a static snapshot since we mutate during traversal
const children = Array.from(node.children) as HTMLElement[];
for (const child of children) {
const tag = child.tagName.toLowerCase();
// drop forbidden elements entirely (including children) by spec: script/iframe/object/embed
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed' || tag === 'style') {
child.remove();
continue;
}
// for tags not on the allowlist, unwrap: keep children, drop the wrapper
if (!ALLOWED_TAGS.has(tag)) {
unwrap(child);
continue;
}
// scrub attributes: remove on* handlers and javascript: URLs
for (const attr of Array.from(child.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith('on')) {
child.removeAttribute(attr.name);
continue;
}
// Block javascript: navigation/handlers; the literal below is a denylist prefix, not a URL.
// eslint-disable-next-line no-script-url
if ((name === 'href' || name === 'src') && value.startsWith('javascript:')) {
child.removeAttribute(attr.name);
}
}
// recurse into surviving element
cleanNode(child);
}
}
function unwrap(el: HTMLElement): void {
const parent = el.parentNode;
if (!parent) return;
// move children before the element, then remove the element
while (el.firstChild) parent.insertBefore(el.firstChild, el);
el.remove();
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
import type {Thread} from './types.ts';
// ---- Thread display-name resolution ---------------------------------------
// Centralised so the backend merge is obvious: swap these functions with
// API-derived fields without touching component templates.
// otherParticipant returns the non-self participant for a DM thread.
export function otherParticipant(t: Thread) {
return t.participants.find((p) => !p.self) ?? t.participants[0];
}
// lastGroupTheme returns the most recent name for a group thread, derived from
// the last "name-change" message, falling back to the thread subject. This makes
// the list title track renames that happened mid-conversation.
export function lastGroupTheme(t: Thread): string {
for (let i = t.messages.length - 1; i >= 0; i--) {
const m = t.messages[i];
if (m.kind === 'name-change') return m.body;
}
return t.subject;
}
// participantLabel returns the display name for a DM participant, falling back
// to the email localpart. This maps to the email "From:" field for the backend merge.
export function participantLabel(t: Thread): string {
const other = otherParticipant(t);
return other.name || other.email.slice(0, other.email.indexOf('@'));
}
// threadDisplayName returns the compact label for the thread list (left bar):
// name only, no email address. Group: last theme; DM: participant label.
export function threadDisplayName(t: Thread): string {
if (t.isGroup) return lastGroupTheme(t);
return participantLabel(t);
}
// threadHeaderTitle returns the full label for the chat header (right panel):
// includes the email address for DMs. Group: last theme; DM: "Name <email>".
export function threadHeaderTitle(t: Thread): string {
if (t.isGroup) return lastGroupTheme(t);
const other = otherParticipant(t);
return `${participantLabel(t)} <${other.email}>`;
}
// threadAvatarEmail returns the email used for avatar resolution.
export function threadAvatarEmail(t: Thread): string {
if (t.isGroup) return '';
return otherParticipant(t).email;
}
+420
View File
@@ -0,0 +1,420 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
// Core domain types for the M8SH Mail frontend. All data is mock-only,
// there is no real backend yet, so everything lives in the MOCK constant below.
export type Participant = {
email: string;
name?: string; // display name override, otherwise derived from localpart
self?: boolean; // marks the current user (Danila)
};
export type MessageKind = 'text' | 'html' | 'subject-change' | 'name-change';
// Attachment is a pure-frontend mock: the data URL lives in memory for the session.
// In a real app this would be a server-uploaded blob reference.
export type Attachment = {
id: string;
name: string;
mime: string;
size: number; // bytes
url: string; // object URL or data URL (in-memory, valid for the session)
kind: 'image' | 'video' | 'audio' | 'file'; // derived from mime for rendering
};
// ForwardedContent is a recursive tree: a forwarded message can itself contain
// forwarded content (a forward of a forward). The UI renders up to MAX_FORWARD_DEPTH
// layers visibly; deeper layers collapse into a folded "N more layers" card.
export type ForwardedContent = {
from: Participant; // original sender of the forwarded message
subject?: string; // subject at the time of the original
body: string; // original body (plain text; HTML forwards keep kind='html' on the carrier)
timestamp: number; // original timestamp
forwarded?: ForwardedContent; // nested forwarded content (one level deeper)
};
export type Message = {
id: string;
sender: Participant; // ignored for subject/name change meta messages
kind: MessageKind;
body: string; // plain text for 'text', sanitized HTML for 'html', the new value for changes
subject?: string; // subject in effect at this message (for DM context)
timestamp: number; // unix ms
attachments?: Attachment[]; // attached files (images, videos, audio, documents)
forwarded?: ForwardedContent; // present when this message is a forward envelope (single)
quotes?: ForwardedContent[]; // multiple forwarded messages grouped together (siblings, same level)
};
export type Thread = {
id: string;
isGroup: boolean;
subject: string; // current subject (DM) or group title
participants: Participant[]; // for DM: [self, other]; for group: [self, ...members]
messages: Message[];
pinned: boolean;
unread: boolean;
};
export type AvatarCache = {
// resolved avatar URL per email ('' means resolved-but-fallback, undefined = pending)
get(email: string): string | undefined;
set(email: string, url: string): void;
};
// ---- Block feature -------------------------------------------------------
// Mock-only: the block list lives in memory for this session. A real backend
// would persist this. 'user' blocks an email; 'domain' blocks the whole domain.
export type BlockScope = 'user' | 'domain';
export type BlockEntry = {
email: string; // exact address for 'user', the domain (no @) for 'domain'
scope: BlockScope;
};
// domainOf returns the part after '@', lowercased, or '' if none.
export function domainOf(email: string): string {
const at = email.indexOf('@');
return at < 0 ? '' : email.slice(at + 1).toLowerCase();
}
// ---- Forwarding feature --------------------------------------------------
// Forwarded messages render recursively. We show up to MAX_FORWARD_DEPTH
// nested layers visibly; anything deeper collapses into a folded "N more
// forwarded layers" card that can be expanded inline.
export const MAX_FORWARD_DEPTH = 2;
// forwardedDepth counts how many nested ForwardedContent layers exist.
export function forwardedDepth(fc: ForwardedContent): number {
let depth = 1;
let cur: ForwardedContent | undefined = fc.forwarded;
while (cur) { depth++; cur = cur.forwarded }
return depth;
}
// ---- Mock data ----------------------------------------------------------
// Hardcoded exactly as specified. Timestamps are fixed offsets from a base so
// the relative ordering reads naturally in the UI.
const now = Date.UTC(2026, 5, 25, 7, 0, 0); // stable base for reproducible previews
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
const self: Participant = {email: 'd@m8sh.su', name: 'Danila', self: true};
// The HTML body for the OpenAI mock email. It is a fixture string that is later
// passed through the allowlist sanitizer (sanitizeHtml) before v-html rendering,
// so the literals here are trusted input, not rendered directly.
/* eslint-disable gitea/unescaped-html-literal */
const openaiHtmlBody = [
'<div style="font-family: var(--fonts-regular, sans-serif);">',
'<h2 style="margin:0 0 8px;">Your weekly summary</h2>',
'<p style="margin:0 0 12px;">Here is what happened across your workspace this week.</p>',
'<table style="width:100%;border-collapse:collapse;">',
'<tr><th style="text-align:left;border-bottom:0.5px solid #ccc;padding:6px 0;">Metric</th>',
'<th style="text-align:right;border-bottom:0.5px solid #ccc;padding:6px 0;">Value</th></tr>',
'<tr><td style="padding:6px 0;">Messages sent</td><td style="text-align:right;">128</td></tr>',
'<tr><td style="padding:6px 0;">Replies received</td><td style="text-align:right;">42</td></tr>',
'</table>',
'<pre style="background:#f2f5f8;padding:8px;border-radius:4px;">commit a1b2c3d\nAuthor: you\nDate: Mon\n ship it</pre>',
'<p style="margin:12px 0 0;">Reply to this email if you have questions.</p>',
'</div>',
].join('');
/* eslint-enable gitea/unescaped-html-literal */
const MOCK: Thread[] = [
{
id: 'dm-danila',
isGroup: false,
subject: 'Weekend plans',
pinned: false,
unread: false,
participants: [
self,
{email: 'd@m8sh.su', name: 'Danila'}, // echoed as the other side is also danila here per spec
],
messages: [
{
id: 'm1',
sender: {email: 'd@m8sh.su', name: 'Danila'},
kind: 'text',
subject: 'Weekend plans',
body: 'Hey, are you free this Saturday? Thinking about a hike up the ridge.',
timestamp: now - 3 * day,
},
{
id: 'm2',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'Weekend plans',
body: 'Saturday works. Bring water and the old map, the trailhead signal is bad.',
timestamp: now - 3 * day + 12 * min,
},
{
id: 'm3',
sender: {email: 'd@m8sh.su', name: 'Danila'},
kind: 'subject-change',
subject: 'Sunday backup plan',
body: 'Sunday backup plan',
timestamp: now - 3 * day + 40 * min,
},
{
id: 'm4',
sender: {email: 'd@m8sh.su', name: 'Danila'},
kind: 'text',
subject: 'Sunday backup plan',
body: 'If it rains we reschedule to Sunday morning, same time. Sound good?',
timestamp: now - 3 * day + 42 * min,
},
],
},
{
id: 'dm-d1nch8g',
isGroup: false,
subject: 'Server logs',
pinned: false,
unread: false,
participants: [
self,
{email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
],
messages: [
{
id: 'd1',
sender: {email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
kind: 'text',
subject: 'Server logs',
body: 'Pulled last nights logs, the worker pool is OOMing around 03:00.',
timestamp: now - day,
},
{
id: 'd2',
sender: {email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
kind: 'text',
subject: 'Server logs',
body: 'Looks like the queue depth spike lines up with the backup job. Coincidence?',
timestamp: now - day + 9 * min,
},
{
id: 'd3',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'Server logs',
body: 'Not a coincidence. I will cap the backup concurrency tonight and watch it.',
timestamp: now - day + 25 * min,
},
],
},
{
id: 'dm-openai',
isGroup: false,
subject: 'Your weekly summary',
pinned: true,
unread: true,
participants: [
self,
{email: 'noreply@email.anthropic.com', name: 'OpenAI'},
],
messages: [
{
id: 'o1',
sender: {email: 'noreply@email.openai.com', name: 'OpenAI'},
kind: 'html',
subject: 'Your weekly summary',
body: openaiHtmlBody,
timestamp: now - 6 * hour,
},
{
id: 'o2',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'Your weekly summary',
body: 'Thanks, the reply count looks lower than usual, any known issue?',
timestamp: now - 5 * hour,
},
{
id: 'o3',
sender: {email: 'noreply@email.openai.com', name: 'OpenAI'},
kind: 'text',
subject: 'Your weekly summary',
body: 'No known issue, counts exclude automated traffic by design.',
timestamp: now - 4 * hour,
},
],
},
{
id: 'group-backend-sync',
isGroup: true,
subject: 'Backend sync',
pinned: false,
unread: false,
participants: [
self,
{email: 'mem@first.com', name: 'mem'},
{email: 'jam@second.com', name: 'jam'},
],
messages: [
{
id: 'g1',
sender: {email: 'mem@first.com', name: 'mem'},
kind: 'text',
subject: 'Backend sync',
body: 'Standup in 5. I am blocked on the auth migration, need a second pair of eyes.',
timestamp: now - 2 * day,
},
{
id: 'g2',
sender: {email: 'jam@second.com', name: 'jam'},
kind: 'text',
subject: 'Backend sync',
body: 'I can pair after standup. Do you have a failing test I can reproduce?',
timestamp: now - 2 * day + 5 * min,
},
{
id: 'g3',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'Backend sync',
body: 'Pushed a repro branch: backend/auth-migration-repro. CI is red, logs attached.',
timestamp: now - 2 * day + 11 * min,
},
{
id: 'g4',
sender: {email: 'mem@first.com', name: 'mem'},
kind: 'name-change',
subject: 'Backend sync',
body: 'Backend sync',
timestamp: now - 2 * day + 30 * min,
},
{
id: 'g5',
sender: {email: 'jam@second.com', name: 'jam'},
kind: 'text',
subject: 'Backend sync',
body: 'Found it, the migration was missing a NOT NULL default. Fix incoming.',
timestamp: now - 2 * day + 48 * min,
},
],
},
{
id: 'dm-random',
isGroup: false,
subject: 'Hello',
pinned: false,
unread: false,
participants: [
self,
{email: 'random@example.com', name: 'random'},
],
messages: [
{
id: 'r1',
sender: {email: 'random@example.com', name: 'random'},
kind: 'text',
subject: 'Hello',
body: 'Hi, found your project through search. Is it open to external contributors?',
timestamp: now - 5 * hour,
},
{
id: 'r2',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'Hello',
body: 'Yes, always. Open an issue first so we can scope it together.',
timestamp: now - 4 * hour,
},
],
},
{
// Forwarding demo: shows a deeply-nested forward chain. The outer message is
// forwarded (layer 1), which itself contains a forwarded message (layer 2),
// which contains another (layer 3) and another (layer 4). Per spec, only the
// first MAX_FORWARD_DEPTH (2) layers render openly; the rest collapse into a
// "N more forwarded layers" card.
id: 'dm-forward-demo',
isGroup: false,
subject: 'FW: nested context',
pinned: false,
unread: true,
participants: [
self,
{email: 'forward@example.com', name: 'forward'},
],
messages: [
{
id: 'f0',
sender: {email: 'forward@example.com', name: 'forward'},
kind: 'text',
subject: 'FW: nested context',
body: 'Here is the full chain for context, see the nested forwards below.',
timestamp: now - 30 * min,
// depth 4: forward(forward(forward(forward(original))))
forwarded: {
from: {email: 'layer1@example.com', name: 'Alice'},
subject: 'Re: spec clarification',
body: 'Layer 1 forward: agreed, let me pull in the original discussion.',
timestamp: now - 2 * hour,
forwarded: {
from: {email: 'layer2@example.com', name: 'Bob'},
subject: 'Re: spec clarification',
body: 'Layer 2 forward: here is the thread that started this, for reference.',
timestamp: now - 5 * hour,
forwarded: {
from: {email: 'layer3@example.com', name: 'Carol'},
subject: 'spec clarification',
body: 'Layer 3 forward: the original question was about depth limits.',
timestamp: now - 9 * hour,
forwarded: {
from: {email: 'layer4@example.com', name: 'Dave'},
subject: 'spec clarification',
body: 'Layer 4 (original): how deep should forwarded nesting render?',
timestamp: now - day,
},
},
},
},
},
{
id: 'f1',
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
kind: 'text',
subject: 'FW: nested context',
body: 'Got it. The depth-2 collapse looks right, the rest stays folded.',
timestamp: now - 10 * min,
},
],
},
];
export function cloneMock(): Thread[] {
// deep clone so in-memory mutations during the session never leak back into MOCK
return structuredClone(MOCK);
}
export const SELF_EMAIL = 'd@m8sh.su';
// attachmentKindOf classifies a mime type (or filename extension) into a rendering category.
// Falls back to extension parsing when the browser doesn't provide a useful mime.
export function attachmentKindOf(mime: string, filename?: string): Attachment['kind'] {
const m = mime.toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
// extension fallback for files where browser reports empty/generic mime
if (filename) {
const ext = filename.toLowerCase().split('.').pop() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'ogv', 'mov', 'avi', 'mkv', 'm4v'].includes(ext)) return 'video';
if (['mp3', 'ogg', 'wav', 'flac', 'aac', 'm4a', 'oga', 'opus'].includes(ext)) return 'audio';
}
return 'file';
}
// formatFileSize formats bytes into a human-readable string.
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,95 @@
// Copyright 2026 The M8SH Authors.
// SPDX-License-Identifier: GPL-3.0
import {reactive} from 'vue';
import {md5} from './md5.ts';
// Resolution state per email: undefined = pending, '' = resolved-to-fallback, otherwise the URL.
export type ResolvedAvatar = string | undefined;
// Module-level shared reactive cache so all AvatarCircle instances reuse results
// and re-render automatically when a resolution lands in the same session.
const cache = reactive(new Map<string, ResolvedAvatar>());
// Track in-flight promises to avoid duplicate network work for the same email.
const inflight = new Map<string, Promise<void>>();
// Kick off (idempotent) resolution for an email. Safe to call repeatedly.
export function resolveAvatar(email: string): void {
const key = email.toLowerCase();
if (cache.has(key) || inflight.has(key)) return;
const p = runChain(key).finally(() => inflight.delete(key));
inflight.set(key, p);
}
export function getAvatar(email: string): ResolvedAvatar {
return cache.get(email.toLowerCase());
}
// Composable returning the reactive cache for components that read many avatars.
export function useAvatarResolver() {
return {cache, resolve: resolveAvatar, get: getAvatar};
}
// The instance default avatar URL (served from the standard Gitea assets path).
// Shown immediately as the fallback; a real avatar replaces it once confirmed.
export const DEFAULT_AVATAR_URL = `${window.config?.assetUrlPrefix ?? ''}/img/avatar_default.png`;
// Avatar resolution pipeline: Gitea -> Gravatar -> Favicon -> Instance default.
async function runChain(email: string): Promise<void> {
const [localpart, domain] = splitEmail(email);
// 1. Gitea avatar: https://<domain>/<localpart>.png (303-redirects to real avatar on m8sh.su).
if (domain && localpart) {
const giteaUrl = `https://${domain}/${localpart}.png`;
if (await probeImage(giteaUrl)) {
cache.set(email, giteaUrl);
return;
}
}
// 2. Gravatar (d=404: only real avatars return 200).
const gravatarUrl = `https://www.gravatar.com/avatar/${md5(email)}?d=404&s=72`;
if (await probeImage(gravatarUrl)) {
cache.set(email, gravatarUrl);
return;
}
// 3. Company favicon via DuckDuckGo on the registrable domain.
// DDG returns 404 for non-existent domains (<img> fires onerror -> probeImage fails)
// and 200 for real domains (<img> fires onload -> probeImage succeeds).
// No CORS issue: <img> loads cross-origin images regardless of CORS headers.
if (domain) {
const ddgUrl = `https://${registrableDomain(domain)}/favicon.ico`;
if (await probeImage(ddgUrl)) {
cache.set(email, ddgUrl);
return;
}
}
// 4. Fallback: the instance's default avatar (the gopher).
cache.set(email, '');
}
function splitEmail(email: string): [string, string] {
const at = email.indexOf('@');
if (at < 0) return ['', ''];
return [email.slice(0, at), email.slice(at + 1)];
}
// Load a URL as an Image and resolve to true only if it actually renders.
// <img> tags follow redirects and can display cross-origin images without CORS headers.
function probeImage(url: string): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
img.addEventListener('load', () => resolve(true));
img.addEventListener('error', () => resolve(false));
img.src = url;
});
}
// registrableDomain extracts the last two labels of a domain (e.g. "openai.com" from "email.openai.com").
function registrableDomain(domain: string): string {
const parts = domain.split('.');
if (parts.length <= 2) return domain;
return parts.slice(-2).join('.');
}
+2
View File
@@ -2,6 +2,7 @@ import '../fomantic/build/fomantic.js';
import '../css/index.css';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initMailApp} from './features/mail/index.ts';
import {initGlobalCopyToClipboardListener} from './modules/clipboard.ts';
import {initCopyContent} from './features/copycontent.ts';
import {initRepoGraphGit} from './features/repo-graph.ts';
@@ -117,6 +118,7 @@ const initPerformanceTracer = callInitFunctions([
initAdminSelfCheck,
initDashboardRepoList,
initMailApp,
initNotificationCount,
+14
View File
@@ -18,6 +18,7 @@ import octiconCheckCircleFill from '../../public/assets/img/svg/octicon-check-ci
import octiconChevronDown from '../../public/assets/img/svg/octicon-chevron-down.svg';
import octiconChevronLeft from '../../public/assets/img/svg/octicon-chevron-left.svg';
import octiconChevronRight from '../../public/assets/img/svg/octicon-chevron-right.svg';
import octiconChevronUp from '../../public/assets/img/svg/octicon-chevron-up.svg';
import octiconCircle from '../../public/assets/img/svg/octicon-circle.svg';
import octiconClock from '../../public/assets/img/svg/octicon-clock.svg';
import octiconCode from '../../public/assets/img/svg/octicon-code.svg';
@@ -59,12 +60,18 @@ import octiconLink from '../../public/assets/img/svg/octicon-link.svg';
import octiconListOrdered from '../../public/assets/img/svg/octicon-list-ordered.svg';
import octiconListUnordered from '../../public/assets/img/svg/octicon-list-unordered.svg';
import octiconLock from '../../public/assets/img/svg/octicon-lock.svg';
import octiconMail from '../../public/assets/img/svg/octicon-mail.svg';
import octiconMeter from '../../public/assets/img/svg/octicon-meter.svg';
import octiconMilestone from '../../public/assets/img/svg/octicon-milestone.svg';
import octiconMirror from '../../public/assets/img/svg/octicon-mirror.svg';
import octiconOrganization from '../../public/assets/img/svg/octicon-organization.svg';
import octiconPaperclip from '../../public/assets/img/svg/octicon-paperclip.svg';
import octiconPaperAirplane from '../../public/assets/img/svg/octicon-paper-airplane.svg';
import octiconPin from '../../public/assets/img/svg/octicon-pin.svg';
import octiconPinSlash from '../../public/assets/img/svg/octicon-pin-slash.svg';
import octiconPlay from '../../public/assets/img/svg/octicon-play.svg';
import octiconPlus from '../../public/assets/img/svg/octicon-plus.svg';
import octiconSmiley from '../../public/assets/img/svg/octicon-smiley.svg';
import octiconProject from '../../public/assets/img/svg/octicon-project.svg';
import octiconQuote from '../../public/assets/img/svg/octicon-quote.svg';
import octiconRepo from '../../public/assets/img/svg/octicon-repo.svg';
@@ -107,6 +114,7 @@ const svgs = {
'octicon-chevron-down': octiconChevronDown,
'octicon-chevron-left': octiconChevronLeft,
'octicon-chevron-right': octiconChevronRight,
'octicon-chevron-up': octiconChevronUp,
'octicon-circle': octiconCircle,
'octicon-clock': octiconClock,
'octicon-code': octiconCode,
@@ -148,13 +156,19 @@ const svgs = {
'octicon-list-ordered': octiconListOrdered,
'octicon-list-unordered': octiconListUnordered,
'octicon-lock': octiconLock,
'octicon-mail': octiconMail,
'octicon-meter': octiconMeter,
'octicon-milestone': octiconMilestone,
'octicon-mirror': octiconMirror,
'octicon-organization': octiconOrganization,
'octicon-paperclip': octiconPaperclip,
'octicon-paper-airplane': octiconPaperAirplane,
'octicon-pin': octiconPin,
'octicon-pin-slash': octiconPinSlash,
'octicon-play': octiconPlay,
'octicon-plus': octiconPlus,
'octicon-project': octiconProject,
'octicon-smiley': octiconSmiley,
'octicon-quote': octiconQuote,
'octicon-repo': octiconRepo,
'octicon-repo-forked': octiconRepoForked,