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 :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"> <div class="flex items-center">
<Avatar v-if="getContact(0)" v-bind="getContact(0)!" class="mr-2" /> <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 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-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> <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="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"> <div class="flex items-center">
<Avatar v-if="getContact(id)" v-bind="getContact(id)!" class="mr-2" /> <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 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> <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> </div>
@ -45,12 +49,13 @@
</template> </template>
<script setup lang="tsx"> <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 { computed, ref } from 'vue';
import type { Item, ItemThread, ThreadRef } from "../state"; import type { Item, ItemThread, ThreadRef } from "../state";
import { items, sources } from '../state'; import { items, sources } from '../state';
import { parseString } from '../utils';
import Avatar from './Avatar.vue'; import Avatar from './Avatar.vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { faCheck, faClockRotateLeft } from '@fortawesome/free-solid-svg-icons';
const props = defineProps<{ const props = defineProps<{
item: Item; item: Item;

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="w-full flex mb-4"> <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')"> <button @click="emits('toggleRead')">
<FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" /> <FontAwesomeIcon class="w-6 h-6" :icon="isRead ? faEnvelopeOpen : faCheck" />
</button> </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)"> <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" /> <Avatar v-if="event.contact !== sourceObj.selfContact" v-bind="sourceObj.contacts[event.contact]" class="self-end" />
<div class="flex flex-col"> <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> <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> </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)"> <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> </div>
</template> </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)"> <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 && event.type === 'message'" @click="startEditing">Edit</button>
<button v-if="ownsMessage" @click="deleteMessage(i)">Delete</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'" @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> <button v-if="event.type === 'message'">Add to garden</button>
</div> </div>
</template> </template>
@ -33,11 +40,12 @@
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="tsx">
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, onUnmounted, ref, watch } from 'vue';
import { items, sources } from '../state';
import Avatar from './Avatar.vue';
import { RouterLink, useRouter } from 'vue-router'; import { RouterLink, useRouter } from 'vue-router';
import { items, sources } from '../state';
import { getRoundedCornersClasses, parseMessage } from '../utils';
import Avatar from './Avatar.vue';
const router = useRouter(); const router = useRouter();
@ -91,19 +99,6 @@ function sendMessage() {
return false; 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 timeout = ref<number>();
const selectedMessageIndex = ref<number>(); const selectedMessageIndex = ref<number>();
@ -183,6 +178,46 @@ function createThread(event: number) {
}); });
router.push(`/source/${props.source}/${props.sourceItem}/${threadId}`); 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> </script>
<style scoped> <style scoped>

View file

@ -1,7 +1,7 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { mergeSortedLists } from "./utils"; import { mergeSortedLists } from "./utils";
export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site"; export type SourceType = "email" | "matrix" | "xmpp" | "rss" | "site" | "todo";
export interface Item { export interface Item {
source: number; source: number;
@ -180,6 +180,35 @@ export const sources = ref<Record<number, Source>>({
} }
}, },
selfContact: 1 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[]>([ export const items = ref<Item[]>([
@ -242,6 +271,20 @@ export const items = ref<Item[]>([
updatedAt: Date.now() 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[]>([]); 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)); 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( watch(
items, items,
items => { items => {

View file

@ -1,6 +1,6 @@
import { Ref, computed, ref, watch } from "vue"; import { Ref, computed, ref, watch } from "vue";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router, RouterLink } from "vue-router";
import { MessageEvent, ThreadRef, items, sources } from "./state"; import { MessageEvent, SourceThread, ThreadRef, items, sources } from "./state";
// Modified from https://www.basedash.com/blog/how-to-merge-two-sorted-lists-in-javascript // 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> { 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 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}</>;
}