diff --git a/apps/zbugs/schema.ts b/apps/zbugs/schema.ts index 50f1285f5c..6c7cc8810a 100644 --- a/apps/zbugs/schema.ts +++ b/apps/zbugs/schema.ts @@ -2,10 +2,10 @@ import { createSchema, createTableSchema, definePermissions, + NOBODY_CAN, type ExpressionBuilder, - type TableSchema, type Row, - NOBODY_CAN, + type TableSchema, } from '@rocicorp/zero'; import type {Condition} from 'zero-protocol/src/ast.js'; @@ -183,6 +183,8 @@ const userPrefSchema = createTableSchema({ export type IssueRow = Row; export type CommentRow = Row; +export type EmojiRow = Row; +export type UserRow = Row; export type Schema = typeof schema; /** The contents of the zbugs JWT */ diff --git a/apps/zbugs/src/components/emoji-panel.tsx b/apps/zbugs/src/components/emoji-panel.tsx index 00699a578d..fb71ac956e 100644 --- a/apps/zbugs/src/components/emoji-panel.tsx +++ b/apps/zbugs/src/components/emoji-panel.tsx @@ -38,14 +38,14 @@ type Props = { issueID: string; commentID?: string | undefined; emojis: readonly Emoji[]; - recentEmojis?: readonly Emoji[] | undefined; + recentEmojiIDs?: readonly string[] | undefined; removeRecentEmoji?: ((id: string) => void) | undefined; }; export const EmojiPanel = memo( forwardRef( ( - {issueID, commentID, emojis, recentEmojis, removeRecentEmoji}: Props, + {issueID, commentID, emojis, recentEmojiIDs, removeRecentEmoji}: Props, ref: ForwardedRef, ) => { const subjectID = commentID ?? issueID; @@ -102,7 +102,7 @@ export const EmojiPanel = memo( normalizedEmoji={normalizedEmoji} emojis={emojis} addOrRemoveEmoji={addOrRemoveEmoji} - recentEmojis={recentEmojis} + recentEmojiIDs={recentEmojiIDs} removeRecentEmoji={removeRecentEmoji} subjectID={subjectID} /> diff --git a/apps/zbugs/src/components/emoji-pill.tsx b/apps/zbugs/src/components/emoji-pill.tsx index d8b32d4df1..96ba3a6540 100644 --- a/apps/zbugs/src/components/emoji-pill.tsx +++ b/apps/zbugs/src/components/emoji-pill.tsx @@ -1,3 +1,4 @@ +import {useQuery} from '@rocicorp/zero/react'; import classNames from 'classnames'; import {useEffect, useState} from 'react'; import {useIntersectionObserver} from 'usehooks-ts'; @@ -27,7 +28,7 @@ type Props = { normalizedEmoji: string; emojis: Emoji[]; addOrRemoveEmoji: AddOrRemoveEmoji; - recentEmojis?: readonly Emoji[] | undefined; + recentEmojiIDs?: readonly string[] | undefined; removeRecentEmoji?: ((id: string) => void) | undefined; subjectID: string; }; @@ -36,7 +37,7 @@ export function EmojiPill({ normalizedEmoji, emojis, addOrRemoveEmoji, - recentEmojis, + recentEmojiIDs, removeRecentEmoji, subjectID, }: Props) { @@ -45,7 +46,7 @@ export function EmojiPill({ const mine = findEmojiForCreator(emojis, z.userID) !== undefined; const [forceShow, setForceShow] = useState(false); const [wasTriggered, setWasTriggered] = useState(false); - const [triggeredEmojis, setTriggeredEmojis] = useState([]); + const [triggeredEmojiIDs, setTriggeredEmojiIDs] = useState([]); const {isIntersecting, ref} = useIntersectionObserver({ threshold: 0.5, freezeOnceVisible: true, @@ -53,18 +54,18 @@ export function EmojiPill({ const documentHasFocus = useDocumentHasFocus(); useEffect(() => { - if (!recentEmojis) { + if (!recentEmojiIDs) { return; } - const newTriggeredEmojis: Emoji[] = []; - for (const emoji of recentEmojis) { - if (emojis.some(e => e.id === emoji.id)) { + const newTriggeredEmojiIDs: string[] = []; + for (const id of recentEmojiIDs) { + if (emojis.some(e => e.id === id)) { setWasTriggered(true); - newTriggeredEmojis.push(emoji); + newTriggeredEmojiIDs.push(id); } } - setTriggeredEmojis(newTriggeredEmojis); - }, [emojis, recentEmojis, subjectID]); + setTriggeredEmojiIDs(newTriggeredEmojiIDs); + }, [emojis, recentEmojiIDs, subjectID]); useEffect(() => { if (wasTriggered && isIntersecting && !forceShow) { @@ -74,22 +75,22 @@ export function EmojiPill({ useEffect(() => { if (forceShow && documentHasFocus && removeRecentEmoji) { - const id = setTimeout(() => { + const timer = setTimeout(() => { setForceShow(false); setWasTriggered(false); - const [first, ...rest] = triggeredEmojis; - if (first) { - removeRecentEmoji(first.id); + const [firstID, ...restIDs] = triggeredEmojiIDs; + if (firstID) { + removeRecentEmoji(firstID); } - setTriggeredEmojis(rest); + setTriggeredEmojiIDs(restIDs); }, triggeredTooltipDuration); - return () => clearTimeout(id); + return () => clearTimeout(timer); } return () => void 0; - }, [triggeredEmojis, documentHasFocus, forceShow, removeRecentEmoji]); + }, [triggeredEmojiIDs, documentHasFocus, forceShow, removeRecentEmoji]); - const triggered = triggeredEmojis.length > 0; + const triggered = triggeredEmojiIDs.length > 0; return ( @@ -118,8 +119,8 @@ export function EmojiPill({ - {triggeredEmojis.length > 0 ? ( - + {triggeredEmojiIDs.length > 0 ? ( + ) : ( formatEmojiCreatorList(emojis, z.userID) )} @@ -128,12 +129,23 @@ export function EmojiPill({ ); } -function TriggeredTooltipContent({emojis}: {emojis: Emoji[]}) { - const emoji = emojis[0]; +function TriggeredTooltipContent({emojiIDs}: {emojiIDs: string[]}) { + const emojiID = emojiIDs[0]; + const z = useZero(); + const [emoji] = useQuery( + z.query.emoji + .where('id', emojiID) + .related('creator', creator => creator.one()) + .one(), + ); + if (!emoji || !emoji.creator) { + return null; + } + return ( <> - - {emoji.creator?.login} + + {emoji.creator.login} ); } diff --git a/apps/zbugs/src/components/filter.tsx b/apps/zbugs/src/components/filter.tsx index 0ad6c3dc5b..5286256988 100644 --- a/apps/zbugs/src/components/filter.tsx +++ b/apps/zbugs/src/components/filter.tsx @@ -5,7 +5,7 @@ import labelIcon from '../assets/icons/label.svg'; import {useZero} from '../hooks/use-zero.js'; import {Button} from './button.js'; import {Combobox} from './combobox.js'; -import UserPicker from './user-picker.js'; +import {UserPicker} from './user-picker.js'; export type Selection = | {creator: string} diff --git a/apps/zbugs/src/components/user-picker.tsx b/apps/zbugs/src/components/user-picker.tsx index 8d1d826c56..59d3874537 100644 --- a/apps/zbugs/src/components/user-picker.tsx +++ b/apps/zbugs/src/components/user-picker.tsx @@ -1,92 +1,86 @@ -import {type Row} from '@rocicorp/zero'; import {useQuery} from '@rocicorp/zero/react'; -import {useEffect, useMemo, useState} from 'react'; -import {type Schema} from '../../schema.js'; +import {memo, useEffect, useMemo, useState} from 'react'; +import {type UserRow} from '../../schema.js'; import avatarIcon from '../assets/icons/avatar-default.svg'; import {useZero} from '../hooks/use-zero.js'; import {Combobox} from './combobox.js'; type Props = { - onSelect?: ((user: User | undefined) => void) | undefined; + onSelect?: ((user: UserRow | undefined) => void) | undefined; selected?: {login?: string | undefined} | undefined; disabled?: boolean | undefined; unselectedLabel?: string | undefined; placeholder?: string | undefined; }; -type User = Row; +export const UserPicker = memo( + ({onSelect, selected, disabled, unselectedLabel, placeholder}: Props) => { + const z = useZero(); -export default function UserPicker({ - onSelect, - selected, - disabled, - unselectedLabel, - placeholder, -}: Props) { - const z = useZero(); + const [unsortedUsers] = useQuery(z.query.user); + // TODO: Support case-insensitive sorting in ZQL. + const users = useMemo( + () => unsortedUsers.toSorted((a, b) => a.login.localeCompare(b.login)), + [unsortedUsers], + ); - const [unsortedUsers] = useQuery(z.query.user); - // TODO: Support case-insensitive sorting in ZQL. - const users = useMemo( - () => unsortedUsers.toSorted((a, b) => a.login.localeCompare(b.login)), - [unsortedUsers], - ); - - // Preload the avatar icons so they show up instantly when opening the - // dropdown. - const [avatars, setAvatars] = useState>({}); - useEffect(() => { - let canceled = false; - async function preload() { - const avatars = await Promise.all(users.map(c => preloadAvatar(c))); - if (canceled) { - return; + // Preload the avatar icons so they show up instantly when opening the + // dropdown. + const [avatars, setAvatars] = useState>({}); + useEffect(() => { + let canceled = false; + async function preload() { + const avatars = await Promise.all(users.map(c => preloadAvatar(c))); + if (canceled) { + return; + } + setAvatars(Object.fromEntries(avatars)); } - setAvatars(Object.fromEntries(avatars)); - } - void preload(); - return () => { - canceled = true; - }; - }, [users]); + void preload(); + return () => { + canceled = true; + }; + }, [users]); - const handleSelect = (user: User | undefined) => { - onSelect?.(user); - }; + const handleSelect = (user: UserRow | undefined) => { + onSelect?.(user); + }; - const selectedUser = selected && users.find(u => u.login === selected.login); + const selectedUser = + selected && users.find(u => u.login === selected.login); - const unselectedItem = { - text: unselectedLabel ?? 'Select', - icon: avatarIcon, - value: undefined, - }; - const defaultItem = { - text: placeholder ?? 'Select a user...', - icon: avatarIcon, - value: undefined, - }; + const unselectedItem = { + text: unselectedLabel ?? 'Select', + icon: avatarIcon, + value: undefined, + }; + const defaultItem = { + text: placeholder ?? 'Select a user...', + icon: avatarIcon, + value: undefined, + }; - return ( - handleSelect(c)} - items={[ - unselectedItem, - ...users.map(u => ({ - text: u.login, - value: u, - icon: avatars[u.id], - })), - ]} - defaultItem={defaultItem} - selectedValue={selectedUser ?? undefined} - className="user-picker" - /> - ); -} + return ( + handleSelect(c)} + items={[ + unselectedItem, + ...users.map(u => ({ + text: u.login, + value: u, + icon: avatars[u.id], + })), + ]} + defaultItem={defaultItem} + selectedValue={selectedUser ?? undefined} + className="user-picker" + /> + ); + }, +); -function preloadAvatar(user: User) { +function preloadAvatar(user: UserRow): Promise<[string, string]> { return new Promise<[string, string]>((res, rej) => { fetch(user.avatar) .then(response => response.blob()) diff --git a/apps/zbugs/src/emoji-utils.ts b/apps/zbugs/src/emoji-utils.ts index 19dd8c1bab..2eb28d2417 100644 --- a/apps/zbugs/src/emoji-utils.ts +++ b/apps/zbugs/src/emoji-utils.ts @@ -1,9 +1,8 @@ -import type {Row} from '@rocicorp/zero'; import {assert} from 'shared/src/asserts.js'; -import type {Schema} from '../schema.js'; +import type {EmojiRow, UserRow} from '../schema.js'; -export type Emoji = Row & { - readonly creator: Row | undefined; +export type Emoji = EmojiRow & { + readonly creator: UserRow | undefined; }; export function formatEmojiCreatorList( diff --git a/apps/zbugs/src/hooks/use-watch-query.tsx b/apps/zbugs/src/hooks/use-watch-query.tsx new file mode 100644 index 0000000000..24e4df96f9 --- /dev/null +++ b/apps/zbugs/src/hooks/use-watch-query.tsx @@ -0,0 +1,151 @@ +import type {Query, QueryType, Row, TableSchema} from '@rocicorp/zero'; +import {useEffect} from 'react'; +import {unreachable} from 'shared/src/asserts.js'; +import type {Change as IVMChange} from 'zql/src/ivm/change.js'; +import type {Input, Output} from 'zql/src/ivm/operator.js'; +import type {Format} from 'zql/src/ivm/view.js'; +import type {AdvancedQuery} from 'zql/src/query/query-internal.js'; + +export function useWatchQuery< + TSchema extends TableSchema, + TReturn extends QueryType, +>( + q: Query, + onChange: (change: Change) => void, + enabled = true, +): void { + useEffect(() => { + if (!enabled) { + return; + } + return (q as AdvancedQuery) + .materialize(changeViewFactory) + .subscribe(onChange); + }, [q, onChange, enabled]); +} + +class WatchQuery implements Output { + readonly #subscribers = new Set>(); + readonly #onDestroy: () => void; + #queryComplete = false; + #pendingChanges: IVMChange[] = []; + + constructor( + input: Input, + onDestroy: () => void = () => void 0, + queryComplete: true | Promise, + ) { + this.#onDestroy = onDestroy; + if (queryComplete === true) { + this.#queryComplete = true; + } else { + queryComplete.then(() => { + this.#queryComplete = true; + this.flush(); + }); + } + input.setOutput(this); + } + + destroy() { + this.#onDestroy(); + } + + push(change: IVMChange): void { + this.#pendingChanges.push(change); + } + + subscribe(cb: Callback): () => void { + this.#subscribers.add(cb); + return () => { + this.#subscribers.delete(cb); + }; + } + + flush() { + if (!this.#queryComplete) { + return; + } + + for (const change of this.#pendingChanges) { + for (const cb of this.#subscribers) { + cb(toChange(change)); + } + } + this.#pendingChanges = []; + } +} + +function changeViewFactory< + TSchema extends TableSchema, + TReturn extends QueryType, +>( + _query: Query, + input: Input, + _format: Format, + onDestroy: () => void, + onTransactionCommit: (cb: () => void) => void, + queryComplete: true | Promise, +): WatchQuery { + const changeView = new WatchQuery(input, onDestroy, queryComplete); + onTransactionCommit(() => { + changeView.flush(); + }); + return changeView; +} + +export type Change = + | { + type: 'add'; + row: Row; + } + | { + type: 'remove'; + row: Row; + } + | { + type: 'child'; + row: Row; + child: { + relationshipName: string; + // TODO: Find this from the relationship. + change: Change; + }; + } + | { + type: 'edit'; + row: Row; + oldRow: Row; + }; + +export type Callback = ( + changes: Change, +) => void; + +function toChange( + change: IVMChange, +): Change { + switch (change.type) { + case 'add': + return {type: 'add', row: change.node.row as Row}; + case 'remove': + return {type: 'remove', row: change.node.row as Row}; + case 'child': + return { + type: 'child', + row: change.row as Row, + child: { + relationshipName: change.child.relationshipName, + change: toChange(change.child.change), + }, + }; + case 'edit': + return { + type: 'edit', + row: change.node.row as Row, + oldRow: change.oldNode.row as Row, + }; + default: + unreachable(change); + } +} diff --git a/apps/zbugs/src/pages/issue/issue-page.tsx b/apps/zbugs/src/pages/issue/issue-page.tsx index 2fa63d351a..8ddcd5664f 100644 --- a/apps/zbugs/src/pages/issue/issue-page.tsx +++ b/apps/zbugs/src/pages/issue/issue-page.tsx @@ -16,11 +16,11 @@ import { } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import {toast, ToastContainer} from 'react-toastify'; -import {assert} from 'shared/src/asserts.js'; +import {assert, unreachable} from 'shared/src/asserts.js'; import {useParams} from 'wouter'; import {navigate, useHistoryState} from 'wouter/use-browser-location'; import {must} from '../../../../../packages/shared/src/must.js'; -import type {CommentRow, IssueRow, Schema} from '../../../schema.js'; +import type {CommentRow, EmojiRow, IssueRow, Schema} from '../../../schema.js'; import statusClosed from '../../assets/icons/issue-closed.svg'; import statusOpen from '../../assets/icons/issue-open.svg'; import {parsePermalink} from '../../comment-permalink.js'; @@ -33,13 +33,13 @@ import LabelPicker from '../../components/label-picker.js'; import {Link} from '../../components/link.js'; import Markdown from '../../components/markdown.js'; import RelativeTime from '../../components/relative-time.js'; -import UserPicker from '../../components/user-picker.js'; -import {type Emoji} from '../../emoji-utils.js'; +import {UserPicker} from '../../components/user-picker.js'; import {useCanEdit} from '../../hooks/use-can-edit.js'; import {useDocumentHasFocus} from '../../hooks/use-document-has-focus.js'; import {useHash} from '../../hooks/use-hash.js'; import {useKeypress} from '../../hooks/use-keypress.js'; import {useLogin} from '../../hooks/use-login.js'; +import {useWatchQuery} from '../../hooks/use-watch-query.js'; import {useZero} from '../../hooks/use-zero.js'; import {LRUCache} from '../../lru-cache.js'; import {links, type ListContext, type ZbugsHistoryState} from '../../routes.js'; @@ -202,14 +202,15 @@ export function IssuePage() { const issueEmojiRef = useRef(null); - const [recentEmojis, setRecentEmojis] = useState([]); + const [recentEmojis, setRecentEmojis] = useState([]); const handleEmojiChange = useCallback( - (added: readonly Emoji[], removed: readonly Emoji[]) => { + (added: EmojiRow | undefined, removed: EmojiRow | undefined) => { assert(issue); - const newRecentEmojis = new Map(recentEmojis.map(e => [e.id, e])); + const newRecentEmojis = new Set(recentEmojis); - for (const emoji of added) { + if (added) { + const emoji = added; if (emoji.creatorID !== z.userID) { maybeShowToastForEmoji( emoji, @@ -218,27 +219,35 @@ export function IssuePage() { issueEmojiRef.current, setRecentEmojis, ); - newRecentEmojis.set(emoji.id, emoji); + newRecentEmojis.add(emoji.id); } } - for (const emoji of removed) { + if (removed) { + const emoji = removed; // toast.dismiss is fine to call with non existing toast IDs toast.dismiss(emoji.id); newRecentEmojis.delete(emoji.id); } - setRecentEmojis([...newRecentEmojis.values()]); + setRecentEmojis([...newRecentEmojis]); }, [issue, recentEmojis, virtualizer, z.userID], ); const removeRecentEmoji = useCallback((id: string) => { toast.dismiss(id); - setRecentEmojis(recentEmojis => recentEmojis.filter(e => e.id !== id)); + setRecentEmojis(recentEmojis => recentEmojis.filter(e => e !== id)); }, []); useEmojiChangeListener(issue, handleEmojiChange); + useWatchQuery( + z.query.comment.where('issueID', issue?.id ?? ''), + useCallback(changes => { + console.log('comments changed', changes); + }, []), + ); + // TODO: We need the notion of the 'partial' result type to correctly render // a 404 here. We can't put the 404 here now because it would flash until we // get data. @@ -347,7 +356,7 @@ export function IssuePage() { issueID={issue.id} ref={issueEmojiRef} emojis={issue.emoji} - recentEmojis={recentEmojis} + recentEmojiIDs={recentEmojis} removeRecentEmoji={removeRecentEmoji} /> @@ -560,16 +569,13 @@ const MyToastContainer = memo(({position}: {position: 'top' | 'bottom'}) => { const commentSizeCache = new LRUCache(1000); function maybeShowToastForEmoji( - emoji: Emoji, + emoji: EmojiRow, issue: IssueRow & {readonly comments: readonly CommentRow[]}, virtualizer: Virtualizer, emojiElement: HTMLDivElement | null, - setRecentEmojis: Dispatch>, + setRecentEmojis: Dispatch>, ) { const toastID = emoji.id; - const {creator} = emoji; - assert(creator); - // We ony show toasts for emojis in the issue itself. Not for emojis in comments. if (emoji.subjectID !== issue.id || !emojiElement) { return; @@ -596,8 +602,7 @@ function maybeShowToastForEmoji( toast( - - {creator.login + ' reacted on this issue: ' + emoji.value} + , { toastId: toastID, @@ -607,8 +612,8 @@ function maybeShowToastForEmoji( // This is so that the emoji that was clicked first is the one that is // shown in the tooltip. setRecentEmojis(emojis => [ - emoji, - ...emojis.filter(e => e.id !== emoji.id), + emoji.id, + ...emojis.filter(e => e !== emoji.id), ]); emojiElement?.scrollIntoView({ @@ -650,6 +655,21 @@ function ToastContent({ ); } +function EmojiToastContent({emoji}: {emoji: EmojiRow}) { + const z = useZero(); + const [creator] = useQuery(z.query.user.where('id', emoji.creatorID).one()); + if (!creator) { + return null; + } + + return ( + <> + + {creator.login + ' reacted on this issue: ' + emoji.value} + + ); +} + function useVirtualComments(comments: readonly T[]) { const defaultHeight = 500; const listRef = useRef(null); @@ -739,64 +759,29 @@ type Issue = IssueRow & { function useEmojiChangeListener( issue: Issue | undefined, - cb: (added: readonly Emoji[], removed: readonly Emoji[]) => void, + cb: (added: EmojiRow | undefined, removed: EmojiRow | undefined) => void, ) { const z = useZero(); - const enable = issue !== undefined; - const issueID = issue?.id; - const [emojis, result] = useQuery( - z.query.emoji - .where('subjectID', issueID ?? '') - .related('creator', creator => creator.one()), - enable, - ); - - const lastIssueID = useRef(); - const lastEmojis = useRef | undefined>(); - - // When the issue.id changes we reset lastEmojis to undefined. - // First time we get the complete emojis for issue.id we update the lastEmojis.current. - // After that as long as issue.id does not change we update lastEmojis.current with the new emojis. - - useEffect(() => { - if (lastIssueID.current !== issueID) { - lastIssueID.current = issueID; - if (result.type === 'unknown') { - lastEmojis.current = undefined; - return; - } - } - - const newEmojis = new Map(emojis.map(emoji => [emoji.id, emoji])); - - // First time we see the complete emojis for this issue. - if (result.type === 'complete' && !lastEmojis.current) { - lastEmojis.current = newEmojis; - // First time should not trigger the callback. - return; - } - - if (lastEmojis.current) { - const added: Emoji[] = []; - const removed: Emoji[] = []; - - for (const [id, emoji] of newEmojis) { - if (!lastEmojis.current.has(id)) { - added.push(emoji); - } - } - - for (const [id, emoji] of lastEmojis.current) { - if (!newEmojis.has(id)) { - removed.push(emoji); - } - } - - if (added.length !== 0 || removed.length !== 0) { - cb(added, removed); + const q = z.query.emoji.where('subjectID', issue?.id ?? ''); + useWatchQuery( + q, + change => { + switch (change.type) { + case 'add': + cb(change.row, undefined); + break; + case 'remove': + cb(undefined, change.row); + break; + case 'edit': + cb(change.row, change.oldRow); + break; + case 'child': + break; + default: + unreachable(change); } - - lastEmojis.current = newEmojis; - } - }, [cb, emojis, issueID, result.type]); + }, + issue !== undefined, + ); }