diff --git a/ui/src/AboutDialog.svelte b/ui/src/AboutDialog.svelte index d8363e5..6c37dd7 100644 --- a/ui/src/AboutDialog.svelte +++ b/ui/src/AboutDialog.svelte @@ -72,7 +72,7 @@ - +

KanDo! is a demonstration Holochain app built by the Holochain Foundation.

Developers: diff --git a/ui/src/AttachmentsDialog.svelte b/ui/src/AttachmentsDialog.svelte index c8becb6..4a9d56a 100644 --- a/ui/src/AttachmentsDialog.svelte +++ b/ui/src/AttachmentsDialog.svelte @@ -79,7 +79,7 @@

Search Linkables:

addAttachment()} > - + { - props.agents = selectedAvatars - handleSave(props) - } + if (!isEqual(props.agents, selectedAvatars)) { + props.agents = selectedAvatars + requestChanges([{ type: "set-card-agents", id: card.id, agents: cloneDeep(props.agents)}]); + } + } const setLabels = () => { props.labels = selectedLabels.map(o => o.value) @@ -276,9 +279,10 @@ {#if showControls}
+ {#if store.weClient}
copyHrlToClipboard()}> - +
{/if} {#if handleDelete} diff --git a/ui/src/KanDoPane.svelte b/ui/src/KanDoPane.svelte index a322114..4cd9ba2 100644 --- a/ui/src/KanDoPane.svelte +++ b/ui/src/KanDoPane.svelte @@ -6,7 +6,7 @@ import type { KanDoStore } from "./store"; import LabelSelector from "./LabelSelector.svelte"; import { v1 as uuidv1 } from "uuid"; - import { type Card, Group, UngroupedId, type CardProps, type Comment, type Checklists, Board, type BoardProps } from "./board"; + import { type Card, Group, UngroupedId, type CardProps, type Comment, type Checklists, Board, type BoardProps, type Feed, type FeedItem, sortedFeedKeys, feedItems, deltaToFeedString } from "./board"; import EditBoardDialog from "./EditBoardDialog.svelte"; import Avatar from "./Avatar.svelte"; import { decodeHashFromBase64, type Timestamp } from "@holochain/client"; @@ -356,13 +356,13 @@ return } const newGroups = cloneDeep($state.groups) - newGroups.push(new Group(newColumnName)) + const group = new Group(newColumnName) newColumnName = "" columnNameElem.value="" activeBoard.requestChanges([ { - type: "set-groups", - groups: newGroups + type: "add-group", + group } ]) } @@ -423,6 +423,7 @@ const attachment: HrlWithContext = { hrl: [store.dnaHash, activeBoard.hash], context: "" } store.weClient?.hrlToClipboard(attachment) } + let feedHidden = true
@@ -461,6 +462,7 @@ + {#if store.weClient} {#if $state.boundTo.length>0} @@ -471,10 +473,10 @@ {/if}
{#if $state.props.attachments}
+ feedHidden = !feedHidden} + style="margin-right:10px" + xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> +
+
+ Activity (latest 50) +
{feedHidden = !feedHidden}}> + +
+ +
+
+ {#each feedItems($state.feed) as item} +
+ + {deltaToFeedString($state,item.content)} + {#if item.content.delta.type == 'set-card-agents'} to: + {#each item.content.delta.agents as agent} + + {/each} + {/if} + + {store.timeAgo.format(item.timestamp)} +
+ {/each} +
+
+ {#if $participants}
@@ -1212,7 +1245,41 @@ transform: scale(1.25); } .hidden { - display: none; + display: none !important; + } + .feed { + border: solid 2px black; + border-radius: 5px; + position: absolute; + top: 30px; + right: 10px; + z-index: 10; + background-color: rgba(255, 255, 255, 0.9); + display:flex; + flex-direction: column; + } + .feed-header { + margin: 5px; + display:flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .feed-items { + padding: 10px; + display:flex; + flex-direction: column; + max-height: 88vh; + overflow: auto; + border-top: solid 1px gray; + padding-top: 5px; + } + .feed-item { + padding: 4px; + border-radius: 5px; + margin-bottom: 5px; + border: solid 1px blue; + background-color: rgba( 0, 0, 255, 0.1); } diff --git a/ui/src/board.ts b/ui/src/board.ts index 2735cdc..410da12 100644 --- a/ui/src/board.ts +++ b/ui/src/board.ts @@ -4,6 +4,7 @@ import { v1 as uuidv1 } from "uuid"; import { type AgentPubKey, type EntryHash, type EntryHashB64, encodeHashToBase64, type AgentPubKeyB64, type Timestamp } from "@holochain/client"; import { BoardType } from "./boardList"; import type { HrlB64WithContext } from "@lightningrodlabs/we-applet"; +import { cloneDeep } from "lodash"; export class LabelDef { type: uuidv1 @@ -71,8 +72,47 @@ export type BoardProps = { attachments: Array } +const MAX_FEED_ITEMS = 50 +export type FeedContent = { + delta: BoardDelta, + context: any, // used to hold info important to generating the feed item description +} + +export type FeedItem = { + timestamp: Date, + content: FeedContent, + author: AgentPubKeyB64, +} + +export type ParsedFeedKey = { + author: AgentPubKeyB64, + timestamp: number, +} + +export type Feed = {[key: string]: FeedContent} // key is agent and timestamp to make unique feed keys to prevent collisions + export type BoardEphemeralState = { [key: string]: string }; +export const parseFeedKey = (key: string) : ParsedFeedKey => { + const [author, timestamp] = key.split(".") + return {author, timestamp: parseInt(timestamp)} +} + +export const sortedFeedKeys = (feed: Feed) => { + const keys = Object.keys(feed) + return keys.map(key=> parseFeedKey(key)).sort(({timestamp:a},{timestamp:b})=>b-a) +} + +export const feedItems = (feed: Feed): FeedItem[] => { + if (!feed) return [] + const parsedKeys: ParsedFeedKey[] = sortedFeedKeys(feed) + return parsedKeys.map(({timestamp, author})=> { + const content = feed[`${author}.${timestamp}`] + const item: FeedItem = {author,timestamp:new Date(timestamp), content} + return item + }) +} + export interface BoardState { status: string; name: string; @@ -83,6 +123,7 @@ export interface BoardState { categoryDefs: CategoryDef[]; props: BoardProps; boundTo: Array + feed: Feed } export type BoardDelta = @@ -107,6 +148,10 @@ export interface BoardState { type: "set-groups"; groups: Group[]; } + | { + type: "add-group"; + group: Group; + } | { type: "set-props"; props: BoardProps; @@ -135,6 +180,11 @@ export interface BoardState { id: uuidv1; props: CardProps; } + | { + type: "set-card-agents"; + id: uuidv1; + agents: AgentPubKeyB64[]; + } | { type: "add-card-comment"; id: uuidv1; @@ -173,7 +223,23 @@ export interface BoardState { type: "delete-card"; id: string; }; - + + const _getCard = (state: BoardState, cardId: uuidv1) : [Card, number] |undefined => { + const index = state.cards.findIndex((card) => card.id === cardId) + if (index >=0) { + return [state.cards[index], index] + } + return undefined + } + + const _getGroup = (state: BoardState, groupId: uuidv1) : [Group, number]|undefined => { + const index = state.groups.findIndex((g) => g.id === groupId) + if (index >=0) { + return [state.groups[index],index] + } + return undefined + } + const _removeCardFromGroups = (state: BoardState, cardId: uuidv1) => { _initGrouping(state) // remove the item from the group it's in @@ -242,6 +308,162 @@ export interface BoardState { }) } + const addToFeed = (state: BoardState, author: AgentPubKeyB64, delta: BoardDelta, context: any): BoardState => { + if (!state.feed) state.feed = {} + + state.feed[`${author}.${Date.now()}`] = {delta, context} + const keys = Object.keys(state) + if (keys.length > MAX_FEED_ITEMS) { + const keysToRemove = keys.map(key=>{ + const [auth, date] = key.split(".") + return [auth,parseInt(date)] + }).sort(([_x, a],[_y,b]) => + // @ts-ignore + a-b).slice(MAX_FEED_ITEMS) + keysToRemove.forEach( ([a,d])=> delete state.feed[`${a}.${d}`]) + } + return state + } + + export const deltaToFeedString = (state: BoardState, content: FeedContent):string => { + const delta = content.delta + const context = content.context + let feedText = "" + switch (delta.type) { + case "set-status": + feedText = `set the board status to ${delta.status}` + break; + case "set-state": + feedText = `set the board ` + break; + case "set-name": + feedText = `set the board name to ${delta.name}` + break; + case "set-props": + feedText = `upated the board settings` + break; + case "add-group": + feedText = `added column "${delta.group.name}"` + break; + case "set-groups": + feedText = `updated the columns` + break; + case "set-group-order": + feedText = `reorded the columns` + break; + case "set-label-defs": + feedText = `updated the labels` + break; + case "set-category-defs": + feedText = `updated the categories` + break; + case "add-card": + feedText = `added a card titled "${delta.value.props.title}" to ${context.group.name}` + break; + case "update-card-group":{ + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + const g =_getGroup(state, delta.group) + if (g) { + const [group,i] = g + if (group) { + feedText = `moved card "${card.props.title}" to ${group.name}` + } else { + feedText = `moved card "${card.props.title}"` + } + } + }} + if (!feedText) feedText = `moved card "${context.card}"` + } + break; + case "update-card-props": + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `updated card "${card.props.title}"` + } + } + if (!feedText) feedText = `updated card "${context.card}"` + + break; + case "set-card-agents": { + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `updated card "${card.props.title}" assignees` + } + } + if (!feedText) feedText = `updated card "${context.card}" assignees` + } + break; + case "add-card-comment": { + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `added comment to card "${card.props.title}"` + } + } + if (!feedText) feedText = `added a comment to card "${context.card}"` + } + break; + case "update-card-comment":{ + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `updated comment on card "${card.props.title}"` + }; + } + if (!feedText) feedText = `updated a comment on card "${context.card}"` + } + break; + case "delete-card-comment": { + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `deleted comment on card "${card.props.title}"` + } + } + if (!feedText) feedText = `deleted a comment on card "${context.card}"` + } + break; + case "add-card-checklist": { + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `added a checklist "${delta.checklist.title}" to card "${card.props.title}"` + } + } + if (!feedText) feedText = `added a checklist "${delta.checklist.title}" to card "${context.card}"` + } + break; + case "update-card-checklist":{ + const c = _getCard(state, delta.id) + if (c) { + const [card,i] = c + if (card) { + feedText = `updated checklist ${delta.title} on card "${card.props.title}"` + }} + if (!feedText) feedText = `updated a checklist "${delta.title}" on card "${context.card}"` + } + break; + case "delete-card-checklist": + feedText = `deleted checklist ${context.checklist} on card "${context.card}"` + break; + case "delete-card": + feedText = `deleted card "${context.card}"` + break; + } + return feedText + } + export const boardGrammar = { initialState(init: Partial|undefined = undefined) { const state: BoardState = { @@ -254,6 +476,7 @@ export interface BoardState { categoryDefs: [], props: {bgUrl:"", attachments:[]}, boundTo: [], + feed: {} } if (init) { Object.assign(state, init); @@ -261,12 +484,14 @@ export interface BoardState { _initGrouping(state) return state }, + applyDelta( delta: BoardDelta, state: BoardState, _ephemeralState: any, - _author: AgentPubKey + author: AgentPubKeyB64 ) { + let feedContext = null switch (delta.type) { case "set-status": state.status = delta.status @@ -297,6 +522,11 @@ export interface BoardState { _initGrouping(state) _setGroups(delta.groups, state) break; + case "add-group": + _initGrouping(state) + state.groups.push(delta.group) + state.grouping[delta.group.id] = [] + break; case "set-group-order": _initGrouping(state) state.grouping[delta.id] = delta.order @@ -316,28 +546,49 @@ export interface BoardState { else { state.grouping[delta.group] = [delta.value.id] } + const g =_getGroup(state, delta.group) + if (g) { + const [group,i] = g + if (group) { + console.log("GG", group) + feedContext = {group: cloneDeep(group)} + } + } break; - case "update-card-group": - _removeCardFromGroups(state, delta.id) - _addCardToGroup(state, delta.group, delta.id, delta.index) + case "update-card-group":{ + const [card,i] = _getCard(state, delta.id) + if (card) { + _removeCardFromGroups(state, delta.id) + _addCardToGroup(state, delta.group, delta.id, delta.index) + feedContext = {card: card.props.title} + }} break; case "update-card-props": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { - state.cards[i].props = delta.props; - } - }); + const [card,i] = _getCard(state, delta.id) + if (card) { + state.cards[i].props = delta.props; + feedContext = {card: card.props.title} + } break; - case "add-card-comment": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { - state.cards[i].comments[delta.comment.id] = delta.comment; - } - }); + case "set-card-agents": { + const [card,i] = _getCard(state, delta.id) + if (card) { + state.cards[i].props.agents = delta.agents; + feedContext = {card: card.props.title} + } + } + break; + case "add-card-comment": { + const [card,i] = _getCard(state, delta.id) + if (card) { + state.cards[i].comments[delta.comment.id] = delta.comment; + feedContext = {card: card.props.title} + } + } break; - case "update-card-comment": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { + case "update-card-comment":{ + const [card,i] = _getCard(state, delta.id) + if (card) { const existingComment = state.cards[i].comments[delta.commentId] if (existingComment) { const comment = { @@ -347,26 +598,30 @@ export interface BoardState { timestamp: new Date().getTime() } state.cards[i].comments[delta.commentId] = comment + feedContext = {card: card.props.title} } - }}); + }; + } break; - case "delete-card-comment": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { - delete state.cards[i].comments[delta.commentId] + case "delete-card-comment": { + const [card,i] = _getCard(state, delta.id) + if (card) { + delete state.cards[i].comments[delta.commentId] + feedContext = {card: card.props.title} } - }); + } break; - case "add-card-checklist": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { + case "add-card-checklist": { + const [card,i] = _getCard(state, delta.id) + if (card) { state.cards[i].checklists[delta.checklist.id] = delta.checklist; + feedContext = {card: card.props.title} } - }); + } break; - case "update-card-checklist": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { + case "update-card-checklist":{ + const [card,i] = _getCard(state, delta.id) + if (card) { const checklist = state.cards[i].checklists[delta.checklistId] if (checklist) { state.cards[i].checklists[delta.checklistId] = { @@ -376,22 +631,29 @@ export interface BoardState { timestamp: new Date().getTime(), order: delta.order, } + feedContext = {card: card.props.title} } - }}); + }}; break; - case "delete-card-checklist": - state.cards.forEach((card, i) => { - if (card.id === delta.id) { - delete state.cards[i].checklists[delta.checklistId] + case "delete-card-checklist":{ + const [card,i] = _getCard(state, delta.id) + if (card) { + feedContext = {checklist: card.checklists[delta.checklistId].title, card:card.props.title} + delete card.checklists[delta.checklistId] } - }); + }; break; - case "delete-card": - const index = state.cards.findIndex((card) => card.id === delta.id) - state.cards.splice(index,1) - _removeCardFromGroups(state, delta.id) + case "delete-card": { + const [card,i] = _getCard(state, delta.id) + if (card) { + state.cards.splice(i,1) + _removeCardFromGroups(state, delta.id) + feedContext = {card: card.props.title} + } + } break; } + state = addToFeed(state, author, delta, feedContext) }, }; @@ -403,10 +665,16 @@ export type BoardStateData = { export class Board { public session: SessionStore | undefined public hashB64: EntryHashB64 + public myAgentKeyB64: AgentPubKeyB64 - constructor(public document: DocumentStore, public workspace: WorkspaceStore) { - this.hashB64 = encodeHashToBase64(this.document.documentHash) - } + constructor( + public document: DocumentStore, + public workspace: WorkspaceStore, + public myAgentKey: AgentPubKey + ) { + this.hashB64 = encodeHashToBase64(this.document.documentHash) + this.myAgentKeyB64 = encodeHashToBase64(myAgentKey) + } public static async Create(synStore: SynStore, init: Partial|undefined = undefined) { const initState = boardGrammar.initialState(init) @@ -420,7 +688,7 @@ export class Board { undefined ); - const me = new Board(documentStore, workspaceStore); + const me = new Board(documentStore, workspaceStore, synStore.client.client.myPubKey); await me.join() if (initState !== undefined) { @@ -476,7 +744,7 @@ export class Board { console.log("REQUESTING BOARD CHANGES: ", deltas) this.session.change((state,_eph)=>{ for (const delta of deltas) { - boardGrammar.applyDelta(delta, state,_eph, undefined) + boardGrammar.applyDelta(delta, state,_eph, this.myAgentKeyB64) } }) } diff --git a/ui/src/boardList.ts b/ui/src/boardList.ts index 556fdaf..a166d39 100644 --- a/ui/src/boardList.ts +++ b/ui/src/boardList.ts @@ -2,10 +2,10 @@ import { HoloHashMap, LazyHoloHashMap } from "@holochain-open-dev/utils"; import { derived, get, writable, type Readable, type Writable } from "svelte/store"; import { type AgentPubKey, type EntryHash, type EntryHashB64, encodeHashToBase64 } from "@holochain/client"; import {toPromise, type AsyncReadable, pipe, joinAsync, asyncDerived, sliceAndJoin, alwaysSubscribed} from '@holochain-open-dev/stores' -import { SynStore, WorkspaceStore } from "@holochain-syn/core"; +import { SynStore, WorkspaceStore, type Commit, stateFromCommit } from "@holochain-syn/core"; import type { ProfilesStore } from "@holochain-open-dev/profiles"; import { cloneDeep } from "lodash"; -import { Board, type BoardDelta, type BoardState } from "./board"; +import { Board, feedItems, type BoardDelta, type BoardState, deltaToFeedString } from "./board"; import { hashEqual } from "./util"; import type { WeClient } from "@lightningrodlabs/we-applet"; import { SeenType } from "./store"; @@ -36,13 +36,14 @@ export class BoardList { activeBoardHash: Writable = writable(undefined) activeBoardHashB64: Readable = derived(this.activeBoardHash, s=> s ? encodeHashToBase64(s): undefined) boardCount: AsyncReadable + notifiedItems = {} boardData2 = new LazyHoloHashMap( documentHash => { const docStore = this.synStore.documents.get(documentHash) const board = pipe(docStore.allWorkspaces, workspaces => { - const board = new Board(docStore, new WorkspaceStore(docStore, Array.from(workspaces.keys())[0])) + const board = new Board(docStore, new WorkspaceStore(docStore, Array.from(workspaces.keys())[0]), this.synStore.client.client.myPubKey) // TODO: fix once we know if our applet is in front or not. if (this.weClient) { board.workspace.tip.subscribe((tip)=>{ @@ -55,15 +56,23 @@ export class BoardList { const activeBoard = get (this.activeBoard) if ((tipB64 != seenTipB64) && (!activeBoard || (encodeHashToBase64(activeBoard.hash) != board.hashB64))) { - this.weClient.notifyWe([{ - title: `Board updated`, - body: "", - notification_type: "change", - icon_src: undefined, - urgency: "low", - timestamp: Date.now() - } - ]) + const boardState = stateFromCommit(tipRecord.entry) as BoardState + const feed = feedItems(boardState.feed) + feed.forEach(feedItem=> { + const key = `${feedItem.author}.${feedItem.timestamp.getTime()}` + if (! this.notifiedItems[key]) { + this.weClient.notifyWe([{ + title: `${boardState.name} updated`, + body: deltaToFeedString(boardState, feedItem.content), + notification_type: "change", + icon_src: undefined, + urgency: "low", + timestamp: Date.now() + } + ]) + this.notifiedItems[key] = true + } + }) } } }) diff --git a/ui/src/svgIcons.ts b/ui/src/svgIcons.ts index e49303c..6f99fd6 100644 --- a/ui/src/svgIcons.ts +++ b/ui/src/svgIcons.ts @@ -25,5 +25,6 @@ export const svgIcons = { faPaperclip: ``, link: ``, faShare: ``, - faGetPocket: `` + addToPocket: ``, + pulse: `` } \ No newline at end of file