mirror of
https://github.com/tloncorp/landscape.git
synced 2024-11-27 15:07:30 +03:00
parent
5e10f1fa7b
commit
57a98606cb
1
ui/.gitignore
vendored
1
ui/.gitignore
vendored
@ -6,3 +6,4 @@ dist-ssr
|
|||||||
stats.html
|
stats.html
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.vercel
|
.vercel
|
||||||
|
storybook-static
|
17
ui/package-lock.json
generated
17
ui/package-lock.json
generated
@ -50,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
"@tailwindcss/aspect-ratio": "^0.2.1",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
"@tloncorp/eslint-config": "^0.0.6",
|
"@tloncorp/eslint-config": "^0.0.6",
|
||||||
"@types/lodash": "^4.14.172",
|
"@types/lodash": "^4.14.172",
|
||||||
"@types/mousetrap": "^1.6.8",
|
"@types/mousetrap": "^1.6.8",
|
||||||
@ -1387,6 +1388,15 @@
|
|||||||
"tailwindcss": ">=2.0.0"
|
"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": {
|
"node_modules/@tlon/sigil-js": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -8700,6 +8710,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {}
|
"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": {
|
"@tlon/sigil-js": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
@ -57,6 +57,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/aspect-ratio": "^0.2.1",
|
"@tailwindcss/aspect-ratio": "^0.2.1",
|
||||||
|
"@tailwindcss/line-clamp": "^0.4.2",
|
||||||
"@tloncorp/eslint-config": "^0.0.6",
|
"@tloncorp/eslint-config": "^0.0.6",
|
||||||
"@types/lodash": "^4.14.172",
|
"@types/lodash": "^4.14.172",
|
||||||
"@types/mousetrap": "^1.6.8",
|
"@types/mousetrap": "^1.6.8",
|
||||||
|
@ -2,13 +2,13 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock Service Worker (0.47.3).
|
* Mock Service Worker (0.43.1).
|
||||||
* @see https://github.com/mswjs/msw
|
* @see https://github.com/mswjs/msw
|
||||||
* - Please do NOT modify this file.
|
* - Please do NOT modify this file.
|
||||||
* - Please do NOT serve this file on production.
|
* - Please do NOT serve this file on production.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995'
|
const INTEGRITY_CHECKSUM = 'c9450df6e4dc5e45740c3b0b640727a2'
|
||||||
const activeClientIds = new Set()
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
self.addEventListener('install', function () {
|
self.addEventListener('install', function () {
|
||||||
@ -200,7 +200,7 @@ async function getResponse(event, client, requestId) {
|
|||||||
|
|
||||||
function passthrough() {
|
function passthrough() {
|
||||||
// Clone the request because it might've been already used
|
// 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())
|
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||||
|
|
||||||
// Remove MSW-specific request headers so the bypassed requests
|
// Remove MSW-specific request headers so the bypassed requests
|
||||||
@ -231,6 +231,13 @@ async function getResponse(event, client, requestId) {
|
|||||||
return passthrough()
|
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.
|
// Notify the client that a request has been intercepted.
|
||||||
const clientMessage = await sendToClient(client, {
|
const clientMessage = await sendToClient(client, {
|
||||||
type: 'REQUEST',
|
type: 'REQUEST',
|
||||||
@ -255,7 +262,11 @@ async function getResponse(event, client, requestId) {
|
|||||||
|
|
||||||
switch (clientMessage.type) {
|
switch (clientMessage.type) {
|
||||||
case 'MOCK_RESPONSE': {
|
case 'MOCK_RESPONSE': {
|
||||||
return respondWithMock(clientMessage.data)
|
return respondWithMock(clientMessage.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_RESPONSE_START': {
|
||||||
|
return respondWithMockStream(operationChannel, clientMessage.payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'MOCK_NOT_FOUND': {
|
case 'MOCK_NOT_FOUND': {
|
||||||
@ -263,13 +274,31 @@ async function getResponse(event, client, requestId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'NETWORK_ERROR': {
|
case 'NETWORK_ERROR': {
|
||||||
const { name, message } = clientMessage.data
|
const { name, message } = clientMessage.payload
|
||||||
const networkError = new Error(message)
|
const networkError = new Error(message)
|
||||||
networkError.name = name
|
networkError.name = name
|
||||||
|
|
||||||
// Rejecting a "respondWith" promise emulates a network error.
|
// Rejecting a "respondWith" promise emulates a network error.
|
||||||
throw networkError
|
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()
|
return passthrough()
|
||||||
@ -287,7 +316,7 @@ function sendToClient(client, message) {
|
|||||||
resolve(event.data)
|
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)
|
await sleep(response.delay)
|
||||||
return new Response(response.body, response)
|
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 },
|
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 = {
|
const rgb = {
|
||||||
r: parseInt(background.slice(1, 3), 16),
|
r: parseInt(background.slice(1, 3), 16),
|
||||||
g: parseInt(background.slice(3, 5), 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 { cite } from '@urbit/api';
|
||||||
import React, { HTMLAttributes } from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
|
import { useCalm } from '../state/settings';
|
||||||
|
import { useContact } from '../state/contact';
|
||||||
|
|
||||||
type ShipNameProps = {
|
type ShipNameProps = {
|
||||||
name: string;
|
name: string;
|
||||||
truncate?: boolean;
|
truncate?: boolean;
|
||||||
|
showAlias?: boolean;
|
||||||
} & HTMLAttributes<HTMLSpanElement>;
|
} & 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 separator = /([_^-])/;
|
||||||
const citedName = truncate ? cite(name) : name;
|
const citedName = truncate ? cite(name) : name;
|
||||||
|
const calm = useCalm();
|
||||||
|
|
||||||
if (!citedName) {
|
if (!citedName) {
|
||||||
return null;
|
return null;
|
||||||
@ -17,20 +27,28 @@ export const ShipName = ({ name, truncate = true, ...props }: ShipNameProps) =>
|
|||||||
const parts = citedName.replace('~', '').split(separator);
|
const parts = citedName.replace('~', '').split(separator);
|
||||||
const first = parts.shift();
|
const first = parts.shift();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span {...props}>
|
<span {...props}>
|
||||||
|
{contact?.nickname && !calm.disableNicknames && showAlias ? (
|
||||||
|
<span title={citedName}>{contact.nickname}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<span aria-hidden>~</span>
|
<span aria-hidden>~</span>
|
||||||
<span>{first}</span>
|
<span>{first}</span>
|
||||||
{parts.length > 1 && (
|
{parts.length > 1 && (
|
||||||
<>
|
<>
|
||||||
{parts.map((piece, index) => (
|
{parts.map((piece, index) => (
|
||||||
<span key={`${piece}-${index}`} aria-hidden={separator.test(piece)}>
|
<span
|
||||||
|
key={`${piece}-${index}`}
|
||||||
|
aria-hidden={separator.test(piece)}
|
||||||
|
>
|
||||||
{piece}
|
{piece}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</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;
|
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 cn from 'classnames';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import React, { useCallback } from 'react';
|
import _ from 'lodash';
|
||||||
import Bullet16Icon from '../../components/icons/Bullet16Icon';
|
|
||||||
import { ShipName } from '../../components/ShipName';
|
|
||||||
import { DeskLink } from '../../components/DeskLink';
|
|
||||||
import { Bin } from './useNotifications';
|
|
||||||
import useHarkState from '../../state/hark';
|
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 { isYarnShip, Rope, YarnContent } from '../../state/hark-types';
|
||||||
import { DocketImage } from '../../components/DocketImage';
|
|
||||||
import { useCharge } from '../../state/docket';
|
import { useCharge } from '../../state/docket';
|
||||||
import { Groups } from './groups';
|
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 {
|
interface NotificationProps {
|
||||||
bin: Bin;
|
bin: Bin;
|
||||||
groups?: Groups;
|
groups?: Groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContent(content: YarnContent) {
|
type NotificationType = 'group-meta' | 'channel' | 'group' | 'desk';
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePrettyTime(date: Date) {
|
function makePrettyTime(date: Date) {
|
||||||
return format(date, 'HH:mm');
|
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) {
|
if (rope.channel) {
|
||||||
return 'channel';
|
return 'channel';
|
||||||
}
|
}
|
||||||
@ -45,10 +48,129 @@ function getNotificationType(rope: Rope) {
|
|||||||
return 'desk';
|
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) {
|
export default function Notification({ bin, groups }: NotificationProps) {
|
||||||
const moreCount = bin.count - 1;
|
const moreCount = bin.count;
|
||||||
const rope = bin.topYarn?.rope;
|
const rope = bin.topYarn?.rope;
|
||||||
const charge = useCharge(rope?.desk);
|
const charge = useCharge(rope?.desk);
|
||||||
|
const app = getAppName(charge);
|
||||||
const type = getNotificationType(rope);
|
const type = getNotificationType(rope);
|
||||||
const ship = bin.topYarn?.con.find(isYarnShip)?.ship;
|
const ship = bin.topYarn?.con.find(isYarnShip)?.ship;
|
||||||
|
|
||||||
@ -61,7 +183,7 @@ export default function Notification({ bin, groups }: NotificationProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex space-x-3 rounded-xl p-3 text-gray-600 transition-colors duration-1000',
|
'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
|
<DeskLink
|
||||||
@ -71,33 +193,41 @@ export default function Notification({ bin, groups }: NotificationProps) {
|
|||||||
className="flex flex-1 space-x-3"
|
className="flex flex-1 space-x-3"
|
||||||
>
|
>
|
||||||
<div className="relative flex-none self-start">
|
<div className="relative flex-none self-start">
|
||||||
<DocketImage {...charge} size="default" />
|
<NotificationTrigger
|
||||||
|
type={type}
|
||||||
|
groups={groups}
|
||||||
|
rope={rope}
|
||||||
|
ship={ship}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 p-1">
|
<div className="w-full flex-col space-y-2">
|
||||||
{(type === 'channel' || type === 'group') && rope.group && (
|
<NotificationContext
|
||||||
<strong>{groups?.[rope.group]?.meta?.title}</strong>
|
groups={groups}
|
||||||
)}
|
type={type}
|
||||||
{type === 'desk' &&
|
rope={rope}
|
||||||
(ship ? (
|
charge={charge}
|
||||||
<ShipName name={ship} className="font-semibold" />
|
app={app}
|
||||||
) : (
|
/>
|
||||||
<strong>{charge?.title || ''}</strong>
|
<div className="">
|
||||||
))}
|
<NotificationContent type={type} content={bin.topYarn?.con} />
|
||||||
<p>{bin.topYarn && bin.topYarn.con.map(getContent)}</p>
|
</div>
|
||||||
{moreCount > 0 ? (
|
{moreCount > 1 ? (
|
||||||
<p className="text-sm font-semibold">
|
<div>
|
||||||
{moreCount} more {pluralize('message', moreCount)} from{' '}
|
<p className="text-sm font-semibold text-gray-600">
|
||||||
{bin.shipCount > 1 ? `${bin.shipCount} people` : '1 person'}
|
Latest of {moreCount} new {pluralize('message', moreCount)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm"> </p>
|
) : null}
|
||||||
|
{bin.topYarn.but?.title && (
|
||||||
|
<Button variant="secondary">{bin.topYarn.but.title}</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DeskLink>
|
</DeskLink>
|
||||||
<div className="flex-none p-1">
|
<div className="flex-none">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{bin.unread ? <Bullet16Icon className="h-4 w-4 text-blue-500" /> : null}
|
<span className="font-semibold text-gray-400">
|
||||||
<span className="font-semibold">{makePrettyTime(new Date(bin.time))}</span>
|
{makePrettyTime(new Date(bin.time))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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">
|
<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 && (
|
{count > 0 && (
|
||||||
<button
|
<button
|
||||||
className="button bg-blue-900 text-white"
|
className="button bg-blue-900 text-white"
|
||||||
@ -59,8 +59,8 @@ export const Notifications = ({ history }: RouteComponentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
{notifications.map((grouping) => (
|
{notifications.map((grouping) => (
|
||||||
<div key={grouping.date}>
|
<div className="mb-4 rounded-xl bg-gray-50 p-4" key={grouping.date}>
|
||||||
<h2 className="mt-8 mb-4 text-lg font-bold text-gray-400">
|
<h2 className="mb-4 text-lg font-bold text-gray-400">
|
||||||
{grouping.date}
|
{grouping.date}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="space-y-2">
|
<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);
|
return useSettingsState(selTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selCalm = (s: SettingsState) => s.calmEngine;
|
||||||
|
export function useCalm() {
|
||||||
|
return useSettingsState(selCalm);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseBrowserSettings(settings: Stringified<BrowserSetting[]>): BrowserSetting[] {
|
export function parseBrowserSettings(settings: Stringified<BrowserSetting[]>): BrowserSetting[] {
|
||||||
return settings !== '' ? JSON.parse<BrowserSetting[]>(settings) : [];
|
return settings !== '' ? JSON.parse<BrowserSetting[]>(settings) : [];
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Docket, DocketHref, Treaty } from '@urbit/api';
|
import { Docket, DocketHref, Treaty } from '@urbit/api';
|
||||||
import { hsla, parseToHsla } from 'color2k';
|
import { hsla, parseToHsla, parseToRgba } from 'color2k';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
export const useMockData = import.meta.env.MODE === 'mock';
|
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}/`;
|
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) {
|
if (!app) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -29,7 +31,9 @@ export function disableDefault<T extends Event>(e: T): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hack until radix-ui fixes this behavior
|
// 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) => {
|
return (e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -76,4 +80,16 @@ export function clearStorageMigration<T>() {
|
|||||||
return {} as 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 {
|
.default-ring {
|
||||||
@apply focus-visible:ring-2 ring-blue-400 ring-opacity-80 focus-visible:outline-none focus:outline-none;
|
@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'],
|
lg: ['1rem', '1.5rem'],
|
||||||
xl: ['1.25rem', '2rem'],
|
xl: ['1.25rem', '2rem'],
|
||||||
'2xl': ['1.5rem', '2rem'],
|
'2xl': ['1.5rem', '2rem'],
|
||||||
'3xl': ['2rem', '3rem']
|
'3xl': ['2rem', '3rem'],
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
minWidth: (theme) => theme('spacing')
|
minWidth: (theme) => theme('spacing'),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
...defaultTheme.screens,
|
...defaultTheme.screens,
|
||||||
xl: '1440px',
|
xl: '1440px',
|
||||||
'2xl': '2200px'
|
'2xl': '2200px',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
extend: {
|
extend: {
|
||||||
opacity: ['hover-none'],
|
opacity: ['hover-none'],
|
||||||
display: ['group-hover']
|
display: ['group-hover'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
require('@tailwindcss/line-clamp'),
|
||||||
require('@tailwindcss/aspect-ratio'),
|
require('@tailwindcss/aspect-ratio'),
|
||||||
require('tailwindcss-touch')(),
|
require('tailwindcss-touch')(),
|
||||||
require('tailwindcss-theming')({
|
require('tailwindcss-theming')({
|
||||||
themes,
|
themes,
|
||||||
strategy: 'class'
|
strategy: 'class',
|
||||||
})
|
}),
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user