garden: new notifications

fixes tloncorp/landscape-apps#1689
This commit is contained in:
James Acklin 2023-01-18 13:54:56 -05:00
parent 5e10f1fa7b
commit 57a98606cb
16 changed files with 467 additions and 78 deletions

1
ui/.gitignore vendored
View File

@ -6,3 +6,4 @@ dist-ssr
stats.html
.eslintcache
.vercel
storybook-static

17
ui/package-lock.json generated
View File

@ -50,6 +50,7 @@
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/line-clamp": "^0.4.2",
"@tloncorp/eslint-config": "^0.0.6",
"@types/lodash": "^4.14.172",
"@types/mousetrap": "^1.6.8",
@ -1387,6 +1388,15 @@
"tailwindcss": ">=2.0.0"
}
},
"node_modules/@tailwindcss/line-clamp": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz",
"integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==",
"dev": true,
"peerDependencies": {
"tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1"
}
},
"node_modules/@tlon/sigil-js": {
"version": "1.4.4",
"license": "MIT",
@ -8700,6 +8710,13 @@
"dev": true,
"requires": {}
},
"@tailwindcss/line-clamp": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz",
"integrity": "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw==",
"dev": true,
"requires": {}
},
"@tlon/sigil-js": {
"version": "1.4.4",
"requires": {

View File

@ -57,6 +57,7 @@
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.2.1",
"@tailwindcss/line-clamp": "^0.4.2",
"@tloncorp/eslint-config": "^0.0.6",
"@types/lodash": "^4.14.172",
"@types/mousetrap": "^1.6.8",

View File

@ -2,13 +2,13 @@
/* tslint:disable */
/**
* Mock Service Worker (0.47.3).
* Mock Service Worker (0.43.1).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995'
const INTEGRITY_CHECKSUM = 'c9450df6e4dc5e45740c3b0b640727a2'
const activeClientIds = new Set()
self.addEventListener('install', function () {
@ -200,7 +200,7 @@ async function getResponse(event, client, requestId) {
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
// (i.e. its body has been read and sent to the cilent).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
@ -231,6 +231,13 @@ async function getResponse(event, client, requestId) {
return passthrough()
}
// Create a communication channel scoped to the current request.
// This way events can be exchanged outside of the worker's global
// "message" event listener (i.e. abstracted into functions).
const operationChannel = new BroadcastChannel(
`msw-response-stream-${requestId}`,
)
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
@ -255,7 +262,11 @@ async function getResponse(event, client, requestId) {
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
return respondWithMock(clientMessage.payload)
}
case 'MOCK_RESPONSE_START': {
return respondWithMockStream(operationChannel, clientMessage.payload)
}
case 'MOCK_NOT_FOUND': {
@ -263,13 +274,31 @@ async function getResponse(event, client, requestId) {
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const { name, message } = clientMessage.payload
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)
return respondWithMock(clientMessage.payload)
}
}
return passthrough()
@ -287,7 +316,7 @@ function sendToClient(client, message) {
resolve(event.data)
}
client.postMessage(message, [channel.port2])
client.postMessage(JSON.stringify(message), [channel.port2])
})
}
@ -301,3 +330,38 @@ async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}
function respondWithMockStream(operationChannel, mockResponse) {
let streamCtrl
const stream = new ReadableStream({
start: (controller) => (streamCtrl = controller),
})
return new Promise(async (resolve, reject) => {
operationChannel.onmessageerror = (event) => {
operationChannel.close()
return reject(event.data.error)
}
operationChannel.onmessage = (event) => {
if (!event.data) {
return
}
switch (event.data.type) {
case 'MOCK_RESPONSE_CHUNK': {
streamCtrl.enqueue(event.data.payload)
break
}
case 'MOCK_RESPONSE_END': {
streamCtrl.close()
operationChannel.close()
}
}
}
await sleep(mockResponse.delay)
return resolve(new Response(stream, mockResponse))
})
}

View File

@ -28,7 +28,9 @@ const sizeMap: Record<AvatarSizes, AvatarMeta> = {
default: { classes: 'w-12 h-12 rounded-lg', size: 24 },
};
const foregroundFromBackground = (background: string): 'black' | 'white' => {
export const foregroundFromBackground = (
background: string
): 'black' | 'white' => {
const rgb = {
r: parseInt(background.slice(1, 3), 16),
g: parseInt(background.slice(3, 5), 16),

View File

@ -0,0 +1,66 @@
import cn from 'classnames';
import React, { useState } from 'react';
import ColorBoxIcon from '../components/icons/ColorBoxIcon';
import { isColor } from '../state/util';
import { useIsDark } from '../logic/useMedia';
import { useCalm } from '../state/settings';
import { useAvatar } from '../state/avatar';
interface GroupAvatarProps {
image?: string;
size?: string;
className?: string;
title?: string;
loadImage?: boolean;
}
const textSize = (size: string) => {
const dims = parseInt(size.replace(/[^0-9.]/g, ''), 10);
switch (dims) {
case 7272:
return 'text-3xl';
case 2020:
return 'text-xl';
case 1616:
return 'text-xl';
case 1414:
return 'text-xl';
case 1212:
return 'text-xl';
case 66:
return 'text-sm';
default:
return 'text-sm';
}
};
export default function GroupAvatar({
image,
size = 'h-6 w-6',
className,
title,
loadImage = true,
}: GroupAvatarProps) {
const { hasLoaded, load } = useAvatar(image || '');
const showImage = hasLoaded || loadImage;
const dark = useIsDark();
const calm = useCalm();
let background;
const symbols = [...(title || '')];
if (showImage && !calm.disableRemoteContent) {
background = image || (dark ? '#333333' : '#E5E5E5');
} else {
background = dark ? '#333333' : '#E5E5E5';
}
return image && showImage && !calm.disableRemoteContent && !isColor(image) ? (
<img className={cn('rounded', size, className)} src={image} onLoad={load} />
) : (
<ColorBoxIcon
className={cn('rounded', size, textSize(size), className)}
color={background}
letter={title ? symbols[0] : ''}
/>
);
}

View File

@ -1,14 +1,24 @@
import { cite } from '@urbit/api';
import React, { HTMLAttributes } from 'react';
import { useCalm } from '../state/settings';
import { useContact } from '../state/contact';
type ShipNameProps = {
name: string;
truncate?: boolean;
showAlias?: boolean;
} & HTMLAttributes<HTMLSpanElement>;
export const ShipName = ({ name, truncate = true, ...props }: ShipNameProps) => {
export function ShipName({
name,
showAlias = false,
truncate = true,
...props
}: ShipNameProps) {
const contact = useContact(name);
const separator = /([_^-])/;
const citedName = truncate ? cite(name) : name;
const calm = useCalm();
if (!citedName) {
return null;
@ -17,20 +27,28 @@ export const ShipName = ({ name, truncate = true, ...props }: ShipNameProps) =>
const parts = citedName.replace('~', '').split(separator);
const first = parts.shift();
return (
<span {...props}>
{contact?.nickname && !calm.disableNicknames && showAlias ? (
<span title={citedName}>{contact.nickname}</span>
) : (
<>
<span aria-hidden>~</span>
<span>{first}</span>
{parts.length > 1 && (
<>
{parts.map((piece, index) => (
<span key={`${piece}-${index}`} aria-hidden={separator.test(piece)}>
<span
key={`${piece}-${index}`}
aria-hidden={separator.test(piece)}
>
{piece}
</span>
))}
</>
)}
</>
)}
</span>
);
};
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import { IconProps } from './icon';
import { foregroundFromBackground } from '../Avatar';
interface ColorBoxIconProps extends IconProps {
color: string;
letter: string;
}
export default function ColorBoxIcon({
className,
color,
letter,
}: ColorBoxIconProps) {
return (
<div
className={classNames(
className,
'flex items-center justify-center rounded-md'
)}
style={{ backgroundColor: color }}
>
<span style={{ color: foregroundFromBackground(color) }}>{letter}</span>
</div>
);
}

View File

@ -19,3 +19,7 @@ export const useMedia = (mediaQuery: string) => {
return match;
};
export function useIsDark() {
return useMedia('(prefers-color-scheme: dark)');
}

View File

@ -1,39 +1,42 @@
import React, { useCallback } from 'react';
import cn from 'classnames';
import { format } from 'date-fns';
import React, { useCallback } from 'react';
import Bullet16Icon from '../../components/icons/Bullet16Icon';
import { ShipName } from '../../components/ShipName';
import { DeskLink } from '../../components/DeskLink';
import { Bin } from './useNotifications';
import _ from 'lodash';
import useHarkState from '../../state/hark';
import { pluralize } from '../../state/util';
import { pluralize, getAppName } from '../../state/util';
import { isYarnShip, Rope, YarnContent } from '../../state/hark-types';
import { DocketImage } from '../../components/DocketImage';
import { useCharge } from '../../state/docket';
import { Groups } from './groups';
import { Bin } from './useNotifications';
import { Button } from '../../components/Button';
import { Avatar } from '../../components/Avatar';
import { ShipName } from '../../components/ShipName';
import { DeskLink } from '../../components/DeskLink';
import { DocketImage } from '../../components/DocketImage';
import GroupAvatar from '../../components/GroupAvatar';
interface NotificationProps {
bin: Bin;
groups?: Groups;
}
function getContent(content: YarnContent) {
if (typeof content === 'string') {
return <span key={content}>{content}</span>;
}
if ('ship' in content) {
return <ShipName key={content.ship} name={content.ship} className="font-semibold text-gray-800" />;
}
return <strong key={content.emph} className="text-gray-800">{content.emph}</strong>;
}
type NotificationType = 'group-meta' | 'channel' | 'group' | 'desk';
function makePrettyTime(date: Date) {
return format(date, 'HH:mm');
}
function getNotificationType(rope: Rope) {
function getNotificationType(rope: Rope): NotificationType {
if (
rope.thread.endsWith('/channel/edit') ||
rope.thread.endsWith('/channel/add') ||
rope.thread.endsWith('/channel/del') ||
rope.thread.endsWith('/joins') ||
rope.thread.endsWith('/leaves')
) {
return 'group-meta';
}
if (rope.channel) {
return 'channel';
}
@ -45,10 +48,129 @@ function getNotificationType(rope: Rope) {
return 'desk';
}
function NotificationTrigger({ type, groups, rope, ship }: any) {
switch (type) {
case 'group-meta':
return (
<GroupAvatar
image={groups?.[rope.group]?.meta?.image}
size="default w-12 h-12"
/>
);
case 'desk':
case 'group':
case 'channel':
return <Avatar shipName={ship} size="default" />;
default:
return null;
}
}
function NotificationContext({ type, groups, rope, charge, app }: any) {
switch (type) {
case 'channel':
return (
<div className="flex items-center space-x-2 text-gray-400">
<GroupAvatar image={groups?.[rope.group]?.meta?.image} />
<span className="font-bold text-gray-400">
{app} {groups?.[rope.group]?.meta?.title}:{' '}
{groups?.[rope.group]?.channels?.[rope.channel]?.meta?.title}
</span>
</div>
);
case 'group':
return (
<div className="flex items-center space-x-2 text-gray-400">
<GroupAvatar image={groups?.[rope.group]?.meta?.image} />
<span className="font-bold text-gray-400">
{app} {groups?.[rope.group]?.meta?.title}
</span>
</div>
);
case 'group-meta':
return (
<div className="flex items-center text-gray-400">
<DocketImage {...charge} size="xs" />
<span className="font-bold text-gray-400">
{app}: {groups?.[rope.group]?.meta?.title}
</span>
</div>
);
case 'desk':
default:
return (
<div className="flex items-center text-gray-400">
<DocketImage {...charge} size="xs" />
<span className="font-bold text-gray-400">{app}</span>
</div>
);
}
}
function NotificationContent({ type, content }: any) {
const mentionRe = new RegExp('mentioned');
const replyRe = new RegExp('replied');
const isMention = type === 'channel' && mentionRe.test(content[1]);
const isReply = type === 'channel' && replyRe.test(content[1]);
function renderContent(c: any) {
if (typeof c === 'string') {
return <span key={c}>{c}</span>;
}
if ('ship' in c) {
return (
<ShipName
key={c.ship}
name={c.ship}
className="font-semibold text-gray-800"
showAlias={true}
/>
);
}
return <span key={c.emph}>&ldquo;{c.emph}&rdquo;</span>;
}
if (isMention) {
return (
<>
<p className="mb-2 leading-5 text-gray-400 line-clamp-2">
{_.map(_.slice(content, 0, 2), (c: YarnContent) => renderContent(c))}
</p>
<p className="leading-5 text-gray-800 line-clamp-2">
{_.map(_.slice(content, 2), (c: YarnContent) => renderContent(c))}
</p>
</>
);
}
if (isReply) {
return (
<>
<p className="mb-2 leading-5 text-gray-400 line-clamp-2">
{_.map(_.slice(content, 0, 4), (c: YarnContent) => renderContent(c))}
</p>
<p className="leading-5 text-gray-800 line-clamp-2">
{_.map(_.slice(content, 6), (c: YarnContent) => renderContent(c))}
</p>
</>
);
}
return (
<p className="leading-5 text-gray-800 line-clamp-2">
{_.map(content, (c: YarnContent) => renderContent(c))}
</p>
);
}
export default function Notification({ bin, groups }: NotificationProps) {
const moreCount = bin.count - 1;
const moreCount = bin.count;
const rope = bin.topYarn?.rope;
const charge = useCharge(rope?.desk);
const app = getAppName(charge);
const type = getNotificationType(rope);
const ship = bin.topYarn?.con.find(isYarnShip)?.ship;
@ -61,7 +183,7 @@ export default function Notification({ bin, groups }: NotificationProps) {
<div
className={cn(
'flex space-x-3 rounded-xl p-3 text-gray-600 transition-colors duration-1000',
bin.unread ? 'bg-blue-50' : 'bg-gray-50'
bin.unread ? 'bg-blue-50 mix-blend-multiply' : 'bg-white'
)}
>
<DeskLink
@ -71,33 +193,41 @@ export default function Notification({ bin, groups }: NotificationProps) {
className="flex flex-1 space-x-3"
>
<div className="relative flex-none self-start">
<DocketImage {...charge} size="default" />
<NotificationTrigger
type={type}
groups={groups}
rope={rope}
ship={ship}
/>
</div>
<div className="space-y-2 p-1">
{(type === 'channel' || type === 'group') && rope.group && (
<strong>{groups?.[rope.group]?.meta?.title}</strong>
)}
{type === 'desk' &&
(ship ? (
<ShipName name={ship} className="font-semibold" />
) : (
<strong>{charge?.title || ''}</strong>
))}
<p>{bin.topYarn && bin.topYarn.con.map(getContent)}</p>
{moreCount > 0 ? (
<p className="text-sm font-semibold">
{moreCount} more {pluralize('message', moreCount)} from{' '}
{bin.shipCount > 1 ? `${bin.shipCount} people` : '1 person'}
<div className="w-full flex-col space-y-2">
<NotificationContext
groups={groups}
type={type}
rope={rope}
charge={charge}
app={app}
/>
<div className="">
<NotificationContent type={type} content={bin.topYarn?.con} />
</div>
{moreCount > 1 ? (
<div>
<p className="text-sm font-semibold text-gray-600">
Latest of {moreCount} new {pluralize('message', moreCount)}
</p>
) : (
<p className="text-sm">&nbsp;</p>
</div>
) : null}
{bin.topYarn.but?.title && (
<Button variant="secondary">{bin.topYarn.but.title}</Button>
)}
</div>
</DeskLink>
<div className="flex-none p-1">
<div className="flex-none">
<div className="flex items-center">
{bin.unread ? <Bullet16Icon className="h-4 w-4 text-blue-500" /> : null}
<span className="font-semibold">{makePrettyTime(new Date(bin.time))}</span>
<span className="font-semibold text-gray-400">
{makePrettyTime(new Date(bin.time))}
</span>
</div>
</div>
</div>

View File

@ -47,7 +47,7 @@ export const Notifications = ({ history }: RouteComponentProps) => {
>
<div className="grid h-full grid-rows-[1fr,auto] overflow-y-auto p-4 sm:grid-rows-[auto,1fr] md:p-9">
<div className="flex w-full items-center justify-between">
<h2 className="text-xl font-semibold">All Notifications</h2>
<h2 className="mb-4 text-xl font-semibold">All Notifications</h2>
{count > 0 && (
<button
className="button bg-blue-900 text-white"
@ -59,8 +59,8 @@ export const Notifications = ({ history }: RouteComponentProps) => {
</div>
<section className="w-full">
{notifications.map((grouping) => (
<div key={grouping.date}>
<h2 className="mt-8 mb-4 text-lg font-bold text-gray-400">
<div className="mb-4 rounded-xl bg-gray-50 p-4" key={grouping.date}>
<h2 className="mb-4 text-lg font-bold text-gray-400">
{grouping.date}
</h2>
<ul className="space-y-2">

33
ui/src/state/avatar.ts Normal file
View File

@ -0,0 +1,33 @@
import produce from 'immer';
import { useCallback } from 'react';
import create from 'zustand';
interface AvatarStore {
status: Record<string, boolean>;
loaded: (src: string) => void;
}
const useAvatarStore = create<AvatarStore>((set, get) => ({
status: {},
loaded: (src) => {
set(
produce((draft) => {
draft.status[src] = true;
})
);
},
}));
export function useAvatar(src: string) {
return useAvatarStore(
useCallback(
(store: AvatarStore) => ({
hasLoaded: store.status[src] || false,
load: () => store.loaded(src),
}),
[src]
)
);
}
export default useAvatarStore;

View File

@ -137,6 +137,11 @@ export function useTheme() {
return useSettingsState(selTheme);
}
const selCalm = (s: SettingsState) => s.calmEngine;
export function useCalm() {
return useSettingsState(selCalm);
}
export function parseBrowserSettings(settings: Stringified<BrowserSetting[]>): BrowserSetting[] {
return settings !== '' ? JSON.parse<BrowserSetting[]>(settings) : [];
}

View File

@ -1,5 +1,5 @@
import { Docket, DocketHref, Treaty } from '@urbit/api';
import { hsla, parseToHsla } from 'color2k';
import { hsla, parseToHsla, parseToRgba } from 'color2k';
import _ from 'lodash';
export const useMockData = import.meta.env.MODE === 'mock';
@ -16,7 +16,9 @@ export function getAppHref(href: DocketHref) {
return 'site' in href ? href.site : `/apps/${href.glob.base}/`;
}
export function getAppName(app: (Docket & { desk: string }) | Treaty | undefined): string {
export function getAppName(
app: (Docket & { desk: string }) | Treaty | undefined
): string {
if (!app) {
return '';
}
@ -29,7 +31,9 @@ export function disableDefault<T extends Event>(e: T): void {
}
// hack until radix-ui fixes this behavior
export function handleDropdownLink(setOpen?: (open: boolean) => void): (e: Event) => void {
export function handleDropdownLink(
setOpen?: (open: boolean) => void
): (e: Event) => void {
return (e: Event) => {
e.stopPropagation();
e.preventDefault();
@ -76,4 +80,16 @@ export function clearStorageMigration<T>() {
return {} as T;
}
export const storageVersion = parseInt(import.meta.env.VITE_STORAGE_VERSION, 10);
export const storageVersion = parseInt(
import.meta.env.VITE_STORAGE_VERSION,
10
);
export function isColor(color: string): boolean {
try {
parseToRgba(color);
return true;
} catch (error) {
return false;
}
}

View File

@ -52,3 +52,7 @@
.default-ring {
@apply focus-visible:ring-2 ring-blue-400 ring-opacity-80 focus-visible:outline-none focus:outline-none;
}
.sb-show-main {
@apply text-base font-sans text-gray-800 bg-white antialiased
}

View File

@ -197,29 +197,30 @@ module.exports = {
lg: ['1rem', '1.5rem'],
xl: ['1.25rem', '2rem'],
'2xl': ['1.5rem', '2rem'],
'3xl': ['2rem', '3rem']
'3xl': ['2rem', '3rem'],
},
extend: {
minWidth: (theme) => theme('spacing')
}
minWidth: (theme) => theme('spacing'),
},
},
screens: {
...defaultTheme.screens,
xl: '1440px',
'2xl': '2200px'
'2xl': '2200px',
},
variants: {
extend: {
opacity: ['hover-none'],
display: ['group-hover']
}
display: ['group-hover'],
},
},
plugins: [
require('@tailwindcss/line-clamp'),
require('@tailwindcss/aspect-ratio'),
require('tailwindcss-touch')(),
require('tailwindcss-theming')({
themes,
strategy: 'class'
})
]
strategy: 'class',
}),
],
};