mirror of
https://github.com/tloncorp/landscape.git
synced 2024-11-27 06:53:33 +03:00
parent
5e10f1fa7b
commit
57a98606cb
1
ui/.gitignore
vendored
1
ui/.gitignore
vendored
@ -6,3 +6,4 @@ dist-ssr
|
||||
stats.html
|
||||
.eslintcache
|
||||
.vercel
|
||||
storybook-static
|
17
ui/package-lock.json
generated
17
ui/package-lock.json
generated
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
@ -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),
|
||||
|
66
ui/src/components/GroupAvatar.tsx
Normal file
66
ui/src/components/GroupAvatar.tsx
Normal 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] : ''}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
27
ui/src/components/icons/ColorBoxIcon.tsx
Normal file
27
ui/src/components/icons/ColorBoxIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -19,3 +19,7 @@ export const useMedia = (mediaQuery: string) => {
|
||||
|
||||
return match;
|
||||
};
|
||||
|
||||
export function useIsDark() {
|
||||
return useMedia('(prefers-color-scheme: dark)');
|
||||
}
|
||||
|
@ -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}>“{c.emph}”</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"> </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>
|
||||
|
@ -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
33
ui/src/state/avatar.ts
Normal 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;
|
@ -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) : [];
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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',
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user