Implement creating TODO items from messages

This commit is contained in:
thepaperpilot 2024-05-27 10:23:19 -05:00
parent d39f752e2e
commit d74e285447
5 changed files with 163 additions and 29 deletions

View file

@ -3,7 +3,9 @@
<div :style="style()" class="p-4 border-2 -mt-2px cursor-pointer bg-gray-200 border-gray-800 select-none relative z-20" :class="isSelected(0) ? 'selected shadow-md -mx-2' : ''" @click="select(0)" @mousedown="e => startDrag(e)" @touchstart="e => startDrag(e)">
<div class="flex items-center">
<Avatar v-if="getContact(0)" v-bind="getContact(0)!" class="mr-2" />
<span class="bg-gray-300 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ item.threads[0].preview }}</span>
<span class="bg-gray-300 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">
<component :is="parseString(item.threads[0].preview)" />
</span>
<span class="grow" />
<span v-if="item.threads[0].count > 0" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ item.threads[0].count }}</span>
<span v-else><FontAwesomeIcon class="w-6 h-6 text-lime-600" :icon="faCheck" /></span>
@ -30,7 +32,9 @@
<div class="p-4 border-2 -mt-2px cursor-pointer bg-slate-300 border-gray-800 select-none relative z-20" :class="isSelected(id) ? 'selected shadow-md -mx-2 thread' : ''" :style="style(parseInt(id as unknown as string))" @click="select(parseInt(id as unknown as string))" @mousemove="drag" @mousedown="e => startDrag(e, id as unknown as string)" @mouseup="endDrag" @touchmove="drag" @touchstart="e => startDrag(e, id as unknown as string)" @touchend="endDrag">
<div class="flex items-center">
<Avatar v-if="getContact(id)" v-bind="getContact(id)!" class="mr-2" />
<span class="bg-gray-100 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">{{ thread.preview }}</span>
<span class="bg-gray-100 px-2 py-1 rounded-2xl block overflow-hidden text-nowrap text-ellipsis">
<component :is="parseString(thread.preview)" />
</span>
<span class="grow" />
<span v-if="thread.count > 1" class="inline-flex items-center justify-center w-3 h-3 p-3 ms-3 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-300">{{ thread.count }}</span>
</div>
@ -45,12 +49,13 @@
</template>
<script setup lang="tsx">
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { computed, ref } from 'vue';
import type { Item, ItemThread, ThreadRef } from "../state";
import { items, sources } from '../state';
import { parseString } from '../utils';
import Avatar from './Avatar.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
const props = defineProps<{
item: Item;

View file

@ -1,6 +1,6 @@
<template>
<div class="w-full flex mb-4">
<div class="text-3xl shrink-0 grow">{{ title }}</div>
<div class="text-3xl grow">{{ title }}</div>
<button @click="emits('toggleRead')">
<FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" />
</button>

View file

@ -8,12 +8,19 @@
<div v-if="i === threadItem.timeline.length - 1 || threadItem.timeline[i + 1].type !== 'message' || threadItem.timeline[i + 1].contact !== event.contact" style="max-width: 80%;" class="flex mb-2" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
<Avatar v-if="event.contact !== sourceObj.selfContact" v-bind="sourceObj.contacts[event.contact]" class="self-end" />
<div class="flex flex-col">
<span class="message px-2 py-1 flex items-center rounded-2xl" :class="getRoundedCornersClasses(i) + (event.contact === sourceObj.selfContact ? ' ml-13' : ' ml-2')">{{ event.message }}</span>
<span :class="getRoundedCornersClasses(threadItem, sourceObj.selfContact, i) +
' message px-2 py-1 flex items-center rounded-2xl' +
(event.contact === sourceObj.selfContact ? ' ml-13' : ' ml-2')">
<component :is="parseMessage(threadItem, i)" />
</span>
<span v-if="event.contact !== sourceObj.selfContact" class="author px-2 text-sm italic mx-2">{{ sourceObj.contacts[event.contact].name }}</span>
</div>
</div>
<div v-else class="flex mb-px" :class="event.contact === sourceObj.selfContact ? 'self self-end flex-row-reverse' : 'other self-start text-gray-200'" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
<span class="message px-2 py-1 flex items-center rounded-2xl ml-13" :class="getRoundedCornersClasses(i)">{{ event.message }}</span>
<span :class="getRoundedCornersClasses(threadItem, sourceObj.selfContact, i) +
' message px-2 py-1 flex items-center rounded-2xl ml-13'">
<component :is="parseMessage(threadItem, i)" />
</span>
</div>
</template>
<div v-else-if="event.type === 'create-thread'" class="self-center mb-2 italic text-gray-500 text-center" @mousedown="start(i)" @mouseup="stop" @contextmenu="e => openmenu(e, i)">
@ -23,7 +30,7 @@
<button v-if="ownsMessage && event.type === 'message'" @click="startEditing">Edit</button>
<button v-if="ownsMessage" @click="deleteMessage(i)">Delete</button>
<button v-if="event.type === 'message'" @click="createThread(i)">Create thread</button>
<button v-if="event.type === 'message'">Create to do</button>
<button v-if="event.type === 'message'" @click="createToDo(i)">Create to do</button>
<button v-if="event.type === 'message'">Add to garden</button>
</div>
</template>
@ -33,11 +40,12 @@
</form>
</template>
<script setup lang="ts">
<script setup lang="tsx">
import { computed, onUnmounted, ref, watch } from 'vue';
import { items, sources } from '../state';
import Avatar from './Avatar.vue';
import { RouterLink, useRouter } from 'vue-router';
import { items, sources } from '../state';
import { getRoundedCornersClasses, parseMessage } from '../utils';
import Avatar from './Avatar.vue';
const router = useRouter();
@ -91,19 +99,6 @@ function sendMessage() {
return false;
}
function getRoundedCornersClasses(index: number) {
const contact = threadItem.value.timeline[index]?.contact;
if (contact == null) {
return "";
}
const side = contact === sourceObj.value.selfContact ? 'r' : 'l';
let classes = `rounded-b${side}-none`;
if (threadItem.value.timeline[index - 1] != null && threadItem.value.timeline[index - 1].type === 'message' && threadItem.value.timeline[index - 1].contact === contact) {
classes += ` rounded-t${side}-none`;
}
return classes;
}
const timeout = ref<number>();
const selectedMessageIndex = ref<number>();
@ -183,6 +178,46 @@ function createThread(event: number) {
});
router.push(`/source/${props.source}/${props.sourceItem}/${threadId}`);
}
function createToDo(event: number) {
const messageEvent = threadItem.value.timeline[event];
if (messageEvent.type !== "message") {
return;
}
const sourceItem = Object.keys(sources.value[1].items).reduce((acc, curr) => Math.max(acc, parseInt(curr)), 0) + 1;
const message = `[[${props.source}/${props.sourceItem}/${props.thread}/${event}]]`;
const newItems = items.value.slice();
newItems.push({
source: 1,
sourceItem,
threads: {
0: {
count: 1,
preview: message,
updatedAt: Date.now(),
contact: 0,
category: 0
}
},
updatedAt: Date.now()
});
items.value = newItems;
sources.value[1].items[sourceItem] = {
id: sourceItem,
threads: {
0: {
id: 0,
timeline: [
{ type: "message", contact: 0, time: Date.now(), message }
],
title: messageEvent.message
}
},
title: messageEvent.message
}
router.push(`/source/1/${sourceItem}/0`);
}
</script>
<style scoped>

View file

@ -1,7 +1,7 @@
import { computed, ref, watch } from "vue";
import { mergeSortedLists } from "./utils";
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site";
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site" | "todo";
export interface Item {
source: number;
@ -180,6 +180,35 @@ export const sources = ref<Record<number, Source>>({
}
},
selfContact: 1
},
1: {
name: "Manual To Do Items",
type: "todo",
id: 1,
selfContact: 0,
items: {
0: {
id: 0,
threads: {
0: {
id: 0,
timeline: [
{ type: "message", contact: 0, time: Date.now(), message: "In the full app I'd like to render this as a list of sub-items, with 'side threads' acting to nest sub-items" }
],
title: "Finish Mockup"
}
},
title: "Finish Mockup"
}
},
contacts: {
0: {
id: 0,
name: "TODO",
status: "unknown",
image: "https://picsum.photos/seed/avatar2/64"
}
}
}
});
export const items = ref<Item[]>([
@ -242,6 +271,20 @@ export const items = ref<Item[]>([
updatedAt: Date.now()
}
}
},
{
source: 1,
sourceItem: 0,
updatedAt: Date.now(),
threads: {
0: {
category: 0,
count: 1,
preview: "Finish Mockup",
contact: 0,
updatedAt: Date.now()
}
}
}
]);
export const unsortedItems = ref<Item[]>([]);
@ -279,8 +322,6 @@ export const todoItems = computed(() =>
);
export const todoCategories = computed(() => categories.value.filter(c => ["urgent", "notify", "todo"].includes(c.priority) && Object.keys(c.activeItems).length > 0));
watch(todoCategories, console.log)
watch(
items,
items => {

View file

@ -1,6 +1,6 @@
import { Ref, computed, ref, watch } from "vue";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { MessageEvent, ThreadRef, items, sources } from "./state";
import { RouteLocationNormalizedLoaded, Router, RouterLink } from "vue-router";
import { MessageEvent, SourceThread, ThreadRef, items, sources } from "./state";
// Modified from https://www.basedash.com/blog/how-to-merge-two-sorted-lists-in-javascript
export function mergeSortedLists<T>(arr1: Array<T>, arr2: Array<T>): Array<T> {
@ -152,3 +152,56 @@ export function setupSelectedThread(baseUrl: Ref<string>, route: RouteLocationNo
toggleRead
};
}
export function getRoundedCornersClasses(thread: SourceThread, selfContact: number, index: number) {
const contact = thread.timeline[index]?.contact;
if (contact == null) {
return "";
}
const side = contact === selfContact ? 'r' : 'l';
let classes = `rounded-b${side}-none`;
if (thread.timeline[index - 1] != null && thread.timeline[index - 1].type === 'message' && thread.timeline[index - 1].contact === contact) {
classes += ` rounded-t${side}-none`;
}
return classes;
}
export function parseMessage(thread: SourceThread, event: number) {
const messageEvent = thread.timeline[event];
if (messageEvent.type !== "message") {
return;
}
const message = messageEvent.message;
return parseString(message);
}
export function parseString(message: string) {
const messageRefs = message.matchAll(/\[\[[0-9]+\/[0-9]+\/[0-9]+\/[0-9]+\]\]/g);
let index = 0;
const parts = [];
for (const match of messageRefs) {
if (index !== match.index) {
parts.push(message.slice(index, match.index));
}
const numbers = match[0].slice(2, -2).split("/");
const sourceId = parseInt(numbers[0]);
const sourceItemId = parseInt(numbers[1]);
const threadId = parseInt(numbers[2]);
const messageId = parseInt(numbers[3]);
const referencedMessage = sources.value[sourceId].items[sourceItemId].threads[threadId].timeline[messageId];
if (referencedMessage.type === "message") {
parts.push(<span class="px-2 bg-emerald-200/25 rounded-2xl">
{ referencedMessage.message }
<RouterLink class="text-slate-600 pl-2 text-sm" to={`/source/${sourceId}/${sourceItemId}/${threadId}`}>
goto message
</RouterLink>
</span>);
}
index = match.index + match[0].length;
}
if (index < message.length) {
parts.push(<span>{ message.slice(index) }</span>)
}
return <>{parts}</>;
}