+
{typeof actions === 'function' ? actions({ hover }) : actions}
) : null}
diff --git a/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx b/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx
index a34b693277..a3d0688b27 100644
--- a/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx
+++ b/apps/tlon-web/src/components/Sidebar/UnreadIndicator.tsx
@@ -1,14 +1,24 @@
import cn from 'classnames';
-import React from 'react';
import ActivityIndicator from './ActivityIndicator';
-export default function UnreadIndicator({ className }: { className?: string }) {
+interface UnreadIndicatorProps {
+ className?: string;
+ count: number;
+ notify?: boolean;
+}
+
+export default function UnreadIndicator({
+ className,
+ count,
+ notify,
+}: UnreadIndicatorProps) {
+ const color = notify ? 'text-blue' : 'text-gray-400';
return (
);
}
diff --git a/apps/tlon-web/src/components/VolumeSetting.tsx b/apps/tlon-web/src/components/VolumeSetting.tsx
index 9dc6b8219f..6b34d9d89d 100644
--- a/apps/tlon-web/src/components/VolumeSetting.tsx
+++ b/apps/tlon-web/src/components/VolumeSetting.tsx
@@ -1,115 +1,90 @@
import {
- LevelNames,
- Scope,
- VolumeValue,
-} from '@tloncorp/shared/dist/urbit/volume';
-import React, { useEffect, useState } from 'react';
+ NotificationLevel,
+ NotificationNames,
+ Source,
+ getDefaultVolumeOption,
+ getLevelFromVolumeMap,
+ getUnreadsFromVolumeMap,
+ getVolumeMap,
+ sourceToString,
+} from '@tloncorp/shared/dist/urbit/activity';
+import React, { useCallback } from 'react';
-import {
- useBaseVolumeSetMutation,
- useGroupChannelVolumeSetMutation,
- useGroupVolumeSetMutation,
- useRouteGroup,
- useVolume,
-} from '@/state/groups';
+import { useVolumeAdjustMutation, useVolumeSettings } from '@/state/activity';
+import { useRouteGroup } from '@/state/groups';
import RadioGroup, { RadioGroupOption } from './RadioGroup';
+import Setting from './Settings/Setting';
-export default function VolumeSetting({ scope }: { scope?: Scope }) {
+export default function VolumeSetting({ source }: { source: Source }) {
const groupFlag = useRouteGroup();
- const [value, setValue] = useState('');
- const { volume: currentVolume, isLoading } = useVolume(scope);
- const { volume: baseVolume, isLoading: baseVolumeIsLoading } = useVolume();
- const { volume: groupVolume, isLoading: groupVolumeIsLoading } = useVolume({
- group: groupFlag,
- });
- const { mutate: setBaseVolume } = useBaseVolumeSetMutation();
- const { mutate: setGroupVoulume } = useGroupVolumeSetMutation();
- const { mutate: setChannelVolume } = useGroupChannelVolumeSetMutation();
+ const { data: settings, isLoading } = useVolumeSettings();
+ const currentSettings = source ? settings[sourceToString(source)] : null;
+ const currentVolume = currentSettings
+ ? getLevelFromVolumeMap(currentSettings)
+ : null;
+ const currentUnreads = currentSettings
+ ? getUnreadsFromVolumeMap(currentSettings)
+ : true;
+ const { label, volume } = getDefaultVolumeOption(source, settings, groupFlag);
+ const { mutate: setVolume, isLoading: settingVolume } =
+ useVolumeAdjustMutation();
const options: RadioGroupOption[] = [
- { label: LevelNames.loud, value: 'loud' },
- { label: LevelNames.soft, value: 'soft' },
- { label: LevelNames.hush, value: 'hush' },
+ { label: NotificationNames.loud, value: 'loud' },
+ { label: NotificationNames.medium, value: 'medium' },
+ { label: NotificationNames.soft, value: 'soft' },
+ { label: NotificationNames.hush, value: 'hush' },
];
- const defaultLevel =
- scope && 'channel' in scope
- ? groupVolume === null
- ? baseVolume
- : groupVolume
- : baseVolume;
-
- if (scope) {
+ if (!('base' in source)) {
options.unshift({
- label:
- 'channel' in scope && groupVolume !== null
- ? 'Use group default'
- : 'Use default setting',
+ label,
value: 'default',
- secondaryLabel: `Your default: ${
- LevelNames[defaultLevel === null ? 'soft' : defaultLevel]
- }`,
+ secondaryLabel: `Your default: ${NotificationNames[volume]}`,
});
}
- useEffect(() => {
- if (value === '' && currentVolume && !isLoading) {
- setValue(currentVolume);
- }
- if (
- value === '' &&
- currentVolume == null &&
- baseVolume &&
- !isLoading &&
- !baseVolumeIsLoading
- ) {
- setValue('default');
- }
- }, [
- currentVolume,
- value,
- isLoading,
- scope,
- baseVolume,
- baseVolumeIsLoading,
- groupVolume,
- groupVolumeIsLoading,
- ]);
-
- useEffect(() => {
- if (value !== '' && currentVolume !== value && !isLoading) {
- if (!scope) {
- setBaseVolume({ volume: value as VolumeValue });
- } else if ('group' in scope) {
- if (value !== 'default') {
- setGroupVoulume({ flag: scope.group, volume: value as VolumeValue });
- } else {
- setGroupVoulume({ flag: scope.group, volume: null });
- }
- } else if ('channel' in scope) {
- if (value !== 'default') {
- setChannelVolume({
- nest: scope.channel,
- volume: value as VolumeValue,
- });
- } else {
- setChannelVolume({
- nest: scope.channel,
- volume: null,
- });
- }
+ const toggle = useCallback(
+ (enabled: boolean) => {
+ if (currentVolume === null) {
+ return;
}
- }
- }, [
- value,
- currentVolume,
- isLoading,
- scope,
- setBaseVolume,
- setGroupVoulume,
- setChannelVolume,
- ]);
- return
;
+ setVolume({
+ source: source || { base: null },
+ volume: getVolumeMap(currentVolume, enabled),
+ });
+ },
+ [source, currentVolume, setVolume]
+ );
+
+ const adjust = useCallback(
+ (value: NotificationLevel) => {
+ setVolume({
+ source: source || { base: null },
+ volume:
+ value === 'default' ? null : getVolumeMap(value, currentUnreads),
+ });
+ },
+ [currentUnreads, source, setVolume]
+ );
+
+ return (
+
+
+ void}
+ options={options}
+ />
+
+ );
}
diff --git a/apps/tlon-web/src/diary/DiaryChannel.tsx b/apps/tlon-web/src/diary/DiaryChannel.tsx
index a1b9281c5b..275525e933 100644
--- a/apps/tlon-web/src/diary/DiaryChannel.tsx
+++ b/apps/tlon-web/src/diary/DiaryChannel.tsx
@@ -13,13 +13,12 @@ import api from '@/api';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import Layout from '@/components/Layout/Layout';
import DiaryGridView from '@/diary/DiaryList/DiaryGridView';
-import { useFullChannel } from '@/logic/channel';
+import { useFullChannel, useMarkChannelRead } from '@/logic/channel';
import useDismissChannelNotifications from '@/logic/useDismissChannelNotifications';
import {
useArrangedPosts,
useDisplayMode,
useInfinitePosts,
- useMarkReadMutation,
useSortMode,
} from '@/state/channel/channel';
import { useRouteGroup } from '@/state/groups/groups';
@@ -47,7 +46,7 @@ function DiaryChannel({ title }: ViewProps) {
hasNextPage,
fetchNextPage,
} = useInfinitePosts(nest);
- const { mutateAsync: markRead, isLoading: isMarking } = useMarkReadMutation();
+ const { markRead, isLoading: isMarking } = useMarkChannelRead(nest);
const loadOlderNotes = useCallback(
(atBottom: boolean) => {
if (atBottom && hasNextPage) {
@@ -133,10 +132,7 @@ function DiaryChannel({ title }: ViewProps) {
useDismissChannelNotifications({
nest,
- markRead: useCallback(
- () => markRead({ nest: `diary/${chFlag}` }),
- [markRead, chFlag]
- ),
+ markRead,
isMarking,
});
diff --git a/apps/tlon-web/src/diary/DiaryNote.tsx b/apps/tlon-web/src/diary/DiaryNote.tsx
index eb4fa8938f..5a9864f1c1 100644
--- a/apps/tlon-web/src/diary/DiaryNote.tsx
+++ b/apps/tlon-web/src/diary/DiaryNote.tsx
@@ -16,7 +16,7 @@ import {
import getKindDataFromEssay from '@/logic/getKindData';
import { useBottomPadding } from '@/logic/position';
import { useGroupsAnalyticsEvent } from '@/logic/useAnalyticsEvent';
-import { getFlagParts, pluralize } from '@/logic/utils';
+import { getFlagParts, getMessageKey, pluralize } from '@/logic/utils';
import ReplyMessage from '@/replies/ReplyMessage';
import { groupReplies, setNewDaysForReplies } from '@/replies/replies';
import {
@@ -25,7 +25,6 @@ import {
usePerms,
usePost,
usePostsOnHost,
- useUnread,
} from '@/state/channel/channel';
import {
useAmAdmin,
@@ -35,6 +34,7 @@ import {
useVessel,
} from '@/state/groups/groups';
import { useDiaryCommentSortMode } from '@/state/settings';
+import { useUnread } from '@/state/unreads';
import { useConnectivityCheck } from '@/state/vitals';
import DiaryCommentField from './DiaryCommentField';
@@ -65,7 +65,7 @@ export default function DiaryNote({ title }: ViewProps) {
const vessel = useVessel(groupFlag, window.our);
const joined = useChannelIsJoined(nest);
const isAdmin = useAmAdmin(groupFlag);
- const unread = useUnread(nest);
+ const unread = useUnread(`channel/${nest}`);
const sort = useDiaryCommentSortMode(chFlag);
const perms = usePerms(nest);
const { paddingBottom } = useBottomPadding();
@@ -161,8 +161,9 @@ export default function DiaryNote({ title }: ViewProps) {
: [];
const canWrite = canWriteChannel(perms, vessel, group?.bloc);
const { title: noteTitle, image } = getKindDataFromEssay(note.essay);
+ const msgKey = getMessageKey(note);
const groupedReplies = setNewDaysForReplies(
- groupReplies(noteId, replyArray, unread).sort(([a], [b]) => {
+ groupReplies(msgKey, replyArray, unread).sort(([a], [b]) => {
if (sort === 'asc') {
return a.localeCompare(b);
}
diff --git a/apps/tlon-web/src/dms/DMOptions.tsx b/apps/tlon-web/src/dms/DMOptions.tsx
index 099bd59302..ad3c5f7d6f 100644
--- a/apps/tlon-web/src/dms/DMOptions.tsx
+++ b/apps/tlon-web/src/dms/DMOptions.tsx
@@ -1,3 +1,4 @@
+import { getKey } from '@tloncorp/shared/dist/urbit/activity';
import cn from 'classnames';
import React, {
PropsWithChildren,
@@ -11,16 +12,15 @@ import { Link } from 'react-router-dom';
import { useChatStore } from '@/chat/useChatStore';
import ActionMenu, { Action } from '@/components/ActionMenu';
import Dialog from '@/components/Dialog';
-import BulletIcon from '@/components/icons/BulletIcon';
+import UnreadIndicator from '@/components/Sidebar/UnreadIndicator';
import EllipsisIcon from '@/components/icons/EllipsisIcon';
-import { useCheckChannelUnread } from '@/logic/channel';
+import { useMarkChannelRead } from '@/logic/channel';
import { useIsMobile } from '@/logic/useMedia';
import { useIsDmOrMultiDm, whomIsDm, whomIsMultiDm } from '@/logic/utils';
-import { useLeaveMutation, useMarkReadMutation } from '@/state/channel/channel';
+import { useLeaveMutation } from '@/state/channel/channel';
import {
useArchiveDm,
useDmRsvpMutation,
- useIsDmUnread,
useMarkDmReadMutation,
useMutliDmRsvpMutation,
} from '@/state/chat';
@@ -29,6 +29,7 @@ import {
useDeletePinMutation,
usePinnedChats,
} from '@/state/pins';
+import { useUnread, useUnreadsStore } from '@/state/unreads';
import DmInviteDialog from './DmInviteDialog';
@@ -62,17 +63,16 @@ export default function DmOptions({
const navigate = useNavigate();
const isMobile = useIsMobile();
const pinned = usePinnedChats();
- const isUnread = useIsDmUnread(whom);
- const isChannelUnread = useCheckChannelUnread();
+ const chatUnread = useUnread(getKey(whom));
const isDMorMultiDm = useIsDmOrMultiDm(whom);
- const hasActivity =
- isUnread || pending || (!isDMorMultiDm && isChannelUnread(whom));
+ const hasNotify = !!chatUnread?.notify;
+ const hasActivity = pending || chatUnread?.status === 'unread';
const { mutate: leaveChat } = useLeaveMutation();
const { mutateAsync: addPin } = useAddPinMutation();
const { mutateAsync: delPin } = useDeletePinMutation();
const { mutate: archiveDm } = useArchiveDm();
- const { mutate: markDmRead } = useMarkDmReadMutation();
- const { mutate: markChannelRead } = useMarkReadMutation();
+ const { markRead: markReadChannel } = useMarkChannelRead(`chat/${whom}`);
+ const { markDmRead } = useMarkDmReadMutation(whom);
const { mutate: multiDmRsvp } = useMutliDmRsvpMutation();
const { mutate: dmRsvp } = useDmRsvpMutation();
@@ -97,13 +97,13 @@ export default function DmOptions({
const markRead = useCallback(async () => {
if (isDMorMultiDm) {
- markDmRead({ whom });
+ markDmRead();
} else {
- markChannelRead({ nest: whom });
+ markReadChannel();
}
- useChatStore.getState().read(whom);
- }, [whom, markDmRead, markChannelRead, isDMorMultiDm]);
+ useUnreadsStore.getState().read(getKey(whom));
+ }, [whom, markReadChannel, markDmRead, isDMorMultiDm]);
const [dialog, setDialog] = useState(false);
@@ -156,12 +156,11 @@ export default function DmOptions({
if (!isHovered && !alwaysShowEllipsis && !isOpen) {
return hasActivity ? (
-
-
-
+
) : null;
}
@@ -241,16 +240,17 @@ export default function DmOptions({
) : (
{!alwaysShowEllipsis && (isMobile || !isOpen) && hasActivity ? (
-
) : null}
{!isMobile && (
();
@@ -67,10 +68,20 @@ export default function DMThread() {
const scrollElementRef = useRef(null);
const isScrolling = useIsScrolling(scrollElementRef);
const { paddingBottom } = useBottomPadding();
- const readTimeout = useChatInfo(whom).unread?.readTimeout;
- const { mutate: markDmRead } = useMarkDmReadMutation();
+ const unreadsKey = getKey(whom);
+ const readTimeout = useUnread(unreadsKey)?.readTimeout;
+ const { markDmRead } = useMarkDmReadMutation(whom);
const isSmall = useMedia('(max-width: 1023px)');
- const clearOnNavRef = useRef({ isSmall, readTimeout, whom, markDmRead });
+ const clearOnNavRef = useRef({
+ isSmall,
+ readTimeout,
+ unreadsKey,
+ markDmRead,
+ });
+ const msgKey: MessageKey = {
+ id,
+ time: formatUd(bigInt(time)),
+ };
const isClub = ship ? (ob.isValidPatp(ship) ? false : true) : false;
const club = useMultiDm(ship || '');
@@ -114,17 +125,18 @@ export default function DMThread() {
);
const onAtBottom = useCallback(() => {
- const { bottom, delayedRead } = useChatStore.getState();
+ const { bottom } = useChatStore.getState();
+ const { delayedRead } = useUnreadsStore.getState();
bottom(true);
- delayedRead(whom, () => markDmRead({ whom }));
- }, [whom, markDmRead]);
+ delayedRead(unreadsKey, markDmRead);
+ }, [unreadsKey, markDmRead]);
useEventListener('keydown', onEscape, threadRef);
// read the messages once navigated away
useEffect(() => {
- clearOnNavRef.current = { isSmall, readTimeout, whom, markDmRead };
- }, [readTimeout, whom, isSmall, markDmRead]);
+ clearOnNavRef.current = { isSmall, readTimeout, unreadsKey, markDmRead };
+ }, [readTimeout, unreadsKey, isSmall, markDmRead]);
useEffect(
() => () => {
@@ -135,8 +147,8 @@ export default function DMThread() {
curr.readTimeout !== 0
) {
chatStoreLogger.log('unmount read from thread');
- useChatStore.getState().read(curr.whom);
- curr.markDmRead({ whom: curr.whom });
+ useUnreadsStore.getState().read(curr.unreadsKey);
+ curr.markDmRead();
}
},
[]
@@ -216,6 +228,7 @@ export default function DMThread() {
key={idTime}
messages={replies}
whom={whom}
+ parent={msgKey}
isLoadingOlder={false}
isLoadingNewer={false}
scrollerRef={scrollerRef}
diff --git a/apps/tlon-web/src/dms/Dm.tsx b/apps/tlon-web/src/dms/Dm.tsx
index eee323208e..d949dba884 100644
--- a/apps/tlon-web/src/dms/Dm.tsx
+++ b/apps/tlon-web/src/dms/Dm.tsx
@@ -1,3 +1,4 @@
+import { getKey } from '@tloncorp/shared/dist/urbit/activity';
import { Contact } from '@tloncorp/shared/dist/urbit/contact';
import cn from 'classnames';
import React, { useCallback, useMemo, useRef } from 'react';
@@ -32,9 +33,10 @@ import { useIsScrolling } from '@/logic/scroll';
import { useIsMobile } from '@/logic/useMedia';
import useMessageSelector from '@/logic/useMessageSelector';
import { dmListPath } from '@/logic/utils';
-import { useDmIsPending, useDmUnread, useSendMessage } from '@/state/chat';
+import { useDmIsPending, useSendMessage } from '@/state/chat';
import { useContact } from '@/state/contact';
import { useNegotiate } from '@/state/negotiation';
+import { useUnread } from '@/state/unreads';
import { useConnectivityCheck } from '@/state/vitals';
import DmSearch from './DmSearch';
@@ -116,7 +118,7 @@ export default function Dm() {
const isMobile = useIsMobile();
const inSearch = useMatch(`/dm/${ship}/search/*`);
const isAccepted = !useDmIsPending(ship);
- const unread = useDmUnread(ship);
+ const unread = useUnread(getKey(ship));
const scrollElementRef = useRef(null);
const isScrolling = useIsScrolling(scrollElementRef);
const canStart = ship && !!unread;
diff --git a/apps/tlon-web/src/dms/DmWindow.tsx b/apps/tlon-web/src/dms/DmWindow.tsx
index 24d08425a2..ab7bbd5409 100644
--- a/apps/tlon-web/src/dms/DmWindow.tsx
+++ b/apps/tlon-web/src/dms/DmWindow.tsx
@@ -1,3 +1,4 @@
+import { getKey } from '@tloncorp/shared/dist/urbit/activity';
import { WritTuple } from '@tloncorp/shared/dist/urbit/dms';
import { udToDec } from '@urbit/api';
import bigInt from 'big-integer';
@@ -7,12 +8,13 @@ import { VirtuosoHandle } from 'react-virtuoso';
import ChatScroller from '@/chat/ChatScroller/ChatScroller';
import ChatScrollerPlaceholder from '@/chat/ChatScroller/ChatScrollerPlaceholder';
-import DMUnreadAlerts from '@/chat/DMUnreadAlerts';
-import { useChatInfo, useChatStore } from '@/chat/useChatStore';
+import DMUnreadAlerts from '@/chat/UnreadAlerts';
+import { useChatStore } from '@/chat/useChatStore';
import ArrowS16Icon from '@/components/icons/ArrowS16Icon';
import { useIsScrolling } from '@/logic/scroll';
import { getPatdaParts, log } from '@/logic/utils';
import { useInfiniteDMs, useMarkDmReadMutation } from '@/state/chat';
+import { useUnread, useUnreadsStore } from '@/state/unreads';
interface DmWindowProps {
whom: string;
@@ -38,11 +40,12 @@ export default function DmWindow({
[searchParams, idTime]
);
const scrollerRef = useRef(null);
- const readTimeout = useChatInfo(whom).unread?.readTimeout;
+ const unreadsKey = getKey(whom);
+ const readTimeout = useUnread(unreadsKey)?.readTimeout;
const scrollElementRef = useRef(null);
const isScrolling = useIsScrolling(scrollElementRef);
- const { mutate: markDmRead } = useMarkDmReadMutation();
- const clearOnNavRef = useRef({ readTimeout, whom, markDmRead });
+ const { markDmRead } = useMarkDmReadMutation(whom);
+ const clearOnNavRef = useRef({ readTimeout, unreadsKey, markDmRead });
const {
writs,
@@ -80,14 +83,15 @@ export default function DmWindow({
);
const onAtBottom = useCallback(() => {
- const { bottom, delayedRead } = useChatStore.getState();
+ const { bottom } = useChatStore.getState();
+ const { delayedRead } = useUnreadsStore.getState();
bottom(true);
- delayedRead(whom, () => markDmRead({ whom }));
+ delayedRead(unreadsKey, markDmRead);
if (hasPreviousPage && !isFetching) {
log('fetching previous page');
fetchPreviousPage();
}
- }, [fetchPreviousPage, hasPreviousPage, isFetching, whom, markDmRead]);
+ }, [fetchPreviousPage, hasPreviousPage, isFetching, unreadsKey, markDmRead]);
const onAtTop = useCallback(() => {
if (hasNextPage && !isFetching) {
@@ -134,15 +138,15 @@ export default function DmWindow({
// read the messages once navigated away
useEffect(() => {
- clearOnNavRef.current = { readTimeout, whom, markDmRead };
- }, [readTimeout, whom, markDmRead]);
+ clearOnNavRef.current = { readTimeout, unreadsKey, markDmRead };
+ }, [readTimeout, unreadsKey, markDmRead]);
useEffect(
() => () => {
const curr = clearOnNavRef.current;
if (curr.readTimeout !== undefined && curr.readTimeout !== 0) {
- useChatStore.getState().read(curr.whom);
- curr.markDmRead({ whom: curr.whom });
+ useUnreadsStore.getState().read(curr.unreadsKey);
+ curr.markDmRead();
}
},
[]
diff --git a/apps/tlon-web/src/dms/MessagesList.tsx b/apps/tlon-web/src/dms/MessagesList.tsx
index c4159130cf..460737fa8a 100644
--- a/apps/tlon-web/src/dms/MessagesList.tsx
+++ b/apps/tlon-web/src/dms/MessagesList.tsx
@@ -1,6 +1,4 @@
-import { Unread } from '@tloncorp/shared/dist/urbit/channel';
-import { DMUnread } from '@tloncorp/shared/dist/urbit/dms';
-import { deSig } from '@urbit/api';
+import { stripSourcePrefix } from '@tloncorp/shared/src/urbit/activity';
import fuzzy from 'fuzzy';
import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react';
import { StateSnapshot, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
@@ -9,19 +7,14 @@ import { InlineEmptyPlaceholder } from '@/components/EmptyPlaceholder';
import { canReadChannel } from '@/logic/channel';
import { useIsMobile } from '@/logic/useMedia';
import useMessageSort from '@/logic/useMessageSort';
-import { whomIsDm, whomIsMultiDm } from '@/logic/utils';
-import { useChats, useUnreads } from '@/state/channel/channel';
+import { useChats } from '@/state/channel/channel';
import { useContacts } from '@/state/contact';
import { useGroups } from '@/state/groups';
import { usePinnedChats } from '@/state/pins';
import { SidebarFilter, filters } from '@/state/settings';
+import { Unread, useUnreads } from '@/state/unreads';
-import {
- useDmUnreads,
- useMultiDms,
- usePendingDms,
- usePendingMultiDms,
-} from '../state/chat';
+import { useMultiDms, usePendingDms, usePendingMultiDms } from '../state/chat';
import MessagesSidebarItem from './MessagesSidebarItem';
type MessagesListProps = PropsWithChildren<{
@@ -31,7 +24,7 @@ type MessagesListProps = PropsWithChildren<{
isScrolling?: (scrolling: boolean) => void;
}>;
-function itemContent(_i: number, [whom, _unread]: [string, Unread | DMUnread]) {
+function itemContent(_i: number, [whom, _unread]: [string, Unread]) {
return (
@@ -39,10 +32,7 @@ function itemContent(_i: number, [whom, _unread]: [string, Unread | DMUnread]) {
);
}
-const computeItemKey = (
- _i: number,
- [whom, _unread]: [string, Unread | DMUnread]
-) => whom;
+const computeItemKey = (_i: number, [whom, _unread]: [string, Unread]) => whom;
let virtuosoState: StateSnapshot | undefined;
@@ -57,15 +47,7 @@ export default function MessagesList({
const pendingMultis = usePendingMultiDms();
const pinned = usePinnedChats();
const { sortMessages } = useMessageSort();
- const { data: dmUnreads } = useDmUnreads();
- const channelUnreads = useUnreads();
- const unreads = useMemo(
- () => ({
- ...channelUnreads,
- ...dmUnreads,
- }),
- [channelUnreads, dmUnreads]
- );
+ const unreads = useUnreads();
const contacts = useContacts();
const clubs = useMultiDms();
const chats = useChats();
@@ -81,75 +63,61 @@ export default function MessagesList({
: { main: 400, reverse: 400 },
};
- const messages = useMemo(() => {
- const filteredMsgs = sortMessages(unreads).filter(([b]) => {
- const chat = chats[b];
- const groupFlag = chat?.perms.group;
- const group = groups[groupFlag || ''];
- const vessel = group?.fleet[window.our];
- const channel = group?.channels[b];
-
- if (
- chat &&
- channel &&
- vessel &&
- !canReadChannel(channel, vessel, group?.bloc)
- ) {
- return false;
- }
-
- if (pinned.includes(b) && !searchQuery) {
- return false;
- }
-
- if (allPending.includes(b)) {
- return false;
- }
-
- if (filter === filters.groups && (whomIsDm(b) || whomIsMultiDm(b))) {
- return false;
- }
-
- if (filter === filters.dms && b.includes('/')) {
- return false;
- }
-
- if (b.includes('/') && !group) {
- return false;
- }
-
- if (searchQuery) {
- if (b.includes('/')) {
- const titleMatch = group.meta.title
- .toLowerCase()
- .startsWith(searchQuery.toLowerCase());
- const shipMatch = deSig(b)?.startsWith(deSig(searchQuery) || '');
- return titleMatch || shipMatch;
+ const organizedUnreads = useMemo(() => {
+ const filteredMsgs = sortMessages(unreads)
+ .filter(([k]) => {
+ if (
+ !(
+ k.startsWith('ship/') ||
+ k.startsWith('club/') ||
+ k.startsWith('channel/')
+ )
+ ) {
+ return false;
}
- if (whomIsDm(b)) {
- const contact = contacts[b];
- const nicknameMatch = contact?.nickname
- .toLowerCase()
- .startsWith(searchQuery.toLowerCase());
- const shipMatch = deSig(b)?.startsWith(deSig(searchQuery) || '');
- return nicknameMatch || shipMatch;
+ const key = stripSourcePrefix(k);
+ const chat = chats[key];
+ const groupFlag = chat?.perms.group;
+ const group = groups[groupFlag || ''];
+ const vessel = group?.fleet[window.our];
+ const channel = group?.channels[key];
+ const isChannel = k.startsWith('channel/');
+ const isDm = k.startsWith('ship/');
+ const isMultiDm = k.startsWith('club/');
+
+ if (
+ chat &&
+ channel &&
+ vessel &&
+ !canReadChannel(channel, vessel, group?.bloc)
+ ) {
+ return false;
}
- if (whomIsMultiDm(b)) {
- const club = clubs[b];
- const titleMatch = club?.meta.title
- ?.toLowerCase()
- .startsWith(searchQuery.toLowerCase());
- const shipsMatch = club?.hive?.some((ship) =>
- deSig(ship)?.startsWith(deSig(searchQuery) || '')
- );
- return titleMatch || shipsMatch;
+ if (pinned.includes(key) && !searchQuery) {
+ return false;
}
- }
- return true; // is all
- });
+ if (allPending.includes(key)) {
+ return false;
+ }
+
+ if (filter === filters.groups && (isDm || isMultiDm)) {
+ return false;
+ }
+
+ if (filter === filters.dms && isChannel) {
+ return false;
+ }
+
+ if (!group && isChannel) {
+ return false;
+ }
+
+ return true; // is all
+ })
+ .map(([k, v]) => [stripSourcePrefix(k), v]) as [string, Unread][];
return !searchQuery
? filteredMsgs
: fuzzy
@@ -237,7 +205,7 @@ export default function MessagesList({
nest in v.channels
)?.[0];
@@ -63,7 +64,7 @@ function ChannelSidebarItem({
{
+ const joinedChannels = Object.entries(unreads).filter(([k, v]) => {
const chat = channels[k];
if (!chat) {
return false;
@@ -26,16 +27,21 @@ export default function TalkHead() {
const vessel = group?.fleet[window.our];
return channel && vessel && canReadChannel(channel, vessel, group.bloc);
});
- const dms = Object.entries(dmUnreads).filter(([k, v]) => {
- const club = multiDms[k];
- if (club) {
- return club.team.concat(club.hive).includes(window.our);
+ const dms = Object.entries(unreads).filter(([k, v]) => {
+ const isClub = k.startsWith('club/');
+ if (!(k.startsWith('ship/') || isClub)) {
+ return false;
+ }
+
+ if (isClub) {
+ const club = multiDms[k];
+ return club ? club.team.concat(club.hive).includes(window.our) : true;
}
return true;
}) as [string, { count: number }][]; // so the types below merge cleanly
- const unreads = useMemo(() => {
+ const unreadsCount = useMemo(() => {
switch (messagesFilter) {
case 'All Messages':
return _.sumBy(Object.values(_.concat(joinedChannels, dms)), 'count');
@@ -50,7 +56,11 @@ export default function TalkHead() {
return (
- {unreads > 0 ? {`(${unreads}) `}Tlon : Tlon }
+ {unreadsCount > 0 ? (
+ {`(${unreadsCount}) `}Tlon
+ ) : (
+ Tlon
+ )}
);
}
diff --git a/apps/tlon-web/src/groups/GroupActions.tsx b/apps/tlon-web/src/groups/GroupActions.tsx
index 82b1f5622a..d5d2ede951 100644
--- a/apps/tlon-web/src/groups/GroupActions.tsx
+++ b/apps/tlon-web/src/groups/GroupActions.tsx
@@ -5,14 +5,13 @@ import React, {
useEffect,
useState,
} from 'react';
-import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
import ActionMenu, { Action } from '@/components/ActionMenu';
import UnreadIndicator from '@/components/Sidebar/UnreadIndicator';
import { useNavWithinTab } from '@/components/Sidebar/util';
import VolumeSetting from '@/components/VolumeSetting';
import EllipsisIcon from '@/components/icons/EllipsisIcon';
-import useIsGroupUnread from '@/logic/useIsGroupUnread';
import { useIsMobile } from '@/logic/useMedia';
import {
citeToPath,
@@ -28,6 +27,7 @@ import {
usePinnedGroups,
} from '@/state/groups';
import { useAddPinMutation, useDeletePinMutation } from '@/state/pins';
+import { useUnread } from '@/state/unreads';
import GroupHostConnection from './GroupHostConnection';
@@ -114,12 +114,11 @@ const GroupActions = React.memo(
children,
}: GroupActionsProps) => {
const [showNotifications, setShowNotifications] = useState(false);
- const { isGroupUnread } = useIsGroupUnread();
const { claim } = useGang(flag);
const location = useLocation();
const { navigate } = useNavWithinTab();
const [host, name] = flag.split('/');
- const hasActivity = isGroupUnread(flag);
+ const activity = useUnread(`group/${flag}`);
const group = useGroup(flag);
const privacy = group ? getPrivacyFromGroup(group) : undefined;
const isAdmin = useAmAdmin(flag);
@@ -302,7 +301,7 @@ const GroupActions = React.memo(
{group?.meta.title || `~${flag}`}
-
+
),
keepOpenOnClick: true,
@@ -349,8 +348,12 @@ const GroupActions = React.memo(
>
{children || (
- {(isMobile || !isOpen) && hasActivity ? (
+ {(isMobile || !isOpen) &&
+ activity &&
+ activity.combined.status !== 'read' ? (
@@ -359,7 +362,11 @@ const GroupActions = React.memo(
{
const isMobile = useIsMobile();
const vessel = useVessel(flag, window.our);
const isChannelJoined = useCheckChannelJoined();
- const isChannelUnread = useCheckChannelUnread();
+ const { isChannelUnread, getUnread } = useCheckChannelUnread();
const virtuosoRef = useRef(null);
useEffect(() => {
@@ -235,7 +235,10 @@ const ChannelList = React.memo(({ paddingTop }: { paddingTop?: number }) => {
to={channelHref(flag, nest)}
actions={
isChannelUnread(nest) ? (
-
+
) : null
}
>
diff --git a/apps/tlon-web/src/groups/GroupVolumeDialog.tsx b/apps/tlon-web/src/groups/GroupVolumeDialog.tsx
index efb9e54b1b..c051365587 100644
--- a/apps/tlon-web/src/groups/GroupVolumeDialog.tsx
+++ b/apps/tlon-web/src/groups/GroupVolumeDialog.tsx
@@ -48,7 +48,7 @@ export default function GroupVolumeDialog({ title }: ViewProps) {
Notification Settings
-
+
);
diff --git a/apps/tlon-web/src/groups/Groups.tsx b/apps/tlon-web/src/groups/Groups.tsx
index 8286af7299..4f9a89ab64 100644
--- a/apps/tlon-web/src/groups/Groups.tsx
+++ b/apps/tlon-web/src/groups/Groups.tsx
@@ -10,7 +10,6 @@ import useGroupPrivacy from '@/logic/useGroupPrivacy';
import { useIsMobile } from '@/logic/useMedia';
import useRecentChannel from '@/logic/useRecentChannel';
import { getFlagParts } from '@/logic/utils';
-import { useUnreads } from '@/state/channel/channel';
import {
useGang,
useGroup,
@@ -21,6 +20,7 @@ import {
useVessel,
} from '@/state/groups/groups';
import { useNewGroupFlags, usePutEntryMutation } from '@/state/settings';
+import { useUnreads } from '@/state/unreads';
function Groups() {
const navigate = useNavigate();
@@ -94,7 +94,7 @@ function Groups() {
const allUnreads = _.mapKeys(unreads, (k, v) => k);
const channel = Object.entries(group.channels).find(
- ([nest]) => nest in allUnreads
+ ([nest]) => `channel/${nest}` in allUnreads
);
canRead = channel && canReadChannel(channel[1], vessel, group?.bloc);
diff --git a/apps/tlon-web/src/heap/HeapChannel.tsx b/apps/tlon-web/src/heap/HeapChannel.tsx
index 0c3729d846..6b28f88d4e 100644
--- a/apps/tlon-web/src/heap/HeapChannel.tsx
+++ b/apps/tlon-web/src/heap/HeapChannel.tsx
@@ -14,11 +14,11 @@ import { PASTEABLE_MEDIA_TYPES } from '@/constants';
import HeapBlock from '@/heap/HeapBlock';
import HeapRow from '@/heap/HeapRow';
import { useDragAndDrop } from '@/logic/DragAndDropContext';
-import { useFullChannel } from '@/logic/channel';
+import { useFullChannel, useMarkChannelRead } from '@/logic/channel';
import getKindDataFromEssay from '@/logic/getKindData';
import useDismissChannelNotifications from '@/logic/useDismissChannelNotifications';
import { useIsMobile } from '@/logic/useMedia';
-import { useInfinitePosts, useMarkReadMutation } from '@/state/channel/channel';
+import { useInfinitePosts } from '@/state/channel/channel';
import { useRouteGroup } from '@/state/groups/groups';
import { useHeapDisplayMode, useHeapSortMode } from '@/state/settings';
import { useUploader } from '@/state/storage';
@@ -48,7 +48,7 @@ function HeapChannel({ title }: ViewProps) {
const sortMode = useHeapSortMode(chFlag);
const { posts, fetchNextPage, hasNextPage, isLoading } =
useInfinitePosts(nest);
- const { mutateAsync: markRead, isLoading: isMarking } = useMarkReadMutation();
+ const { markRead, isLoading: isMarking } = useMarkChannelRead(nest);
const dropZoneId = useMemo(() => `new-curio-input-${chFlag}`, [chFlag]);
const { isDragging, isOver, droppedFiles, setDroppedFiles } =
@@ -63,7 +63,7 @@ function HeapChannel({ title }: ViewProps) {
useDismissChannelNotifications({
nest,
- markRead: useCallback(() => markRead({ nest }), [markRead, nest]),
+ markRead,
isMarking,
});
diff --git a/apps/tlon-web/src/heap/HeapDetail.tsx b/apps/tlon-web/src/heap/HeapDetail.tsx
index decfd7b06d..329cf6b0dc 100644
--- a/apps/tlon-web/src/heap/HeapDetail.tsx
+++ b/apps/tlon-web/src/heap/HeapDetail.tsx
@@ -15,6 +15,7 @@ import getKindDataFromEssay from '@/logic/getKindData';
import { useBottomPadding } from '@/logic/position';
import { useGroupsAnalyticsEvent } from '@/logic/useAnalyticsEvent';
import { useIsMobile } from '@/logic/useMedia';
+import { getMessageKey } from '@/logic/utils';
import {
useIsPostUndelivered,
useOrderedPosts,
@@ -53,6 +54,7 @@ export default function HeapDetail({ title }: ViewProps) {
const { title: curioTitle } = getKindDataFromEssay(note?.essay);
const { paddingBottom } = useBottomPadding();
const essay = note?.essay || initialNote?.essay;
+ const msgKey = note ? getMessageKey(note) : { id: '', time: '' };
const curioHref = (id?: bigInt.BigInteger) => {
if (!id) {
@@ -144,7 +146,7 @@ export default function HeapDetail({ title }: ViewProps) {
{idTime && !isUndelivered && (
diff --git a/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx b/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx
index 56c9fd158c..ac5846329a 100644
--- a/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx
+++ b/apps/tlon-web/src/heap/HeapDetail/HeapDetailSidebar/HeapDetailComments.tsx
@@ -1,3 +1,4 @@
+import { MessageKey } from '@tloncorp/shared/dist/urbit/activity';
import { ReplyTuple } from '@tloncorp/shared/dist/urbit/channel';
import bigInt from 'big-integer';
import { useMemo } from 'react';
@@ -8,20 +9,21 @@ import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner';
import { canWriteChannel, useChannelFlag } from '@/logic/channel';
import ReplyMessage from '@/replies/ReplyMessage';
import { groupReplies, setNewDaysForReplies } from '@/replies/replies';
-import { usePerms, useUnread } from '@/state/channel/channel';
+import { usePerms } from '@/state/channel/channel';
import { useGroup, useRouteGroup, useVessel } from '@/state/groups/groups';
import { useDiaryCommentSortMode } from '@/state/settings';
+import { useUnread } from '@/state/unreads';
import HeapDetailCommentField from './HeapDetailCommentField';
interface HeapDetailCommentsProps {
- time: string;
+ parent: MessageKey;
comments: ReplyTuple[] | null;
loading: boolean;
}
export default function HeapDetailComments({
- time,
+ parent,
comments,
loading,
}: HeapDetailCommentsProps) {
@@ -38,7 +40,7 @@ export default function HeapDetailComments({
const sort = useDiaryCommentSortMode(chFlag ?? '');
const vessel = useVessel(groupFlag, window.our);
const canWrite = canWriteChannel(perms, vessel, group?.bloc);
- const unread = useUnread(nest);
+ const unread = useUnread(`thread/${nest}/${parent.id}`);
const sortedComments =
comments?.sort(([a], [b]) => {
if (sort === 'asc') {
@@ -49,7 +51,7 @@ export default function HeapDetailComments({
const groupedReplies = !comments
? []
: setNewDaysForReplies(
- groupReplies(time, sortedComments, unread).sort(([a], [b]) => {
+ groupReplies(parent, sortedComments, unread).sort(([a], [b]) => {
if (sort === 'asc') {
return a.localeCompare(b);
}
diff --git a/apps/tlon-web/src/logic/channel.ts b/apps/tlon-web/src/logic/channel.ts
index 4e1cce9ad4..fa7989143d 100644
--- a/apps/tlon-web/src/logic/channel.ts
+++ b/apps/tlon-web/src/logic/channel.ts
@@ -1,4 +1,9 @@
-import { Perm, Story, Unreads } from '@tloncorp/shared/dist/urbit/channel';
+import {
+ Activity,
+ MessageKey,
+ Source,
+} from '@tloncorp/shared/dist/urbit/activity';
+import { Perm, Story } from '@tloncorp/shared/dist/urbit/channel';
import { isLink } from '@tloncorp/shared/dist/urbit/content';
import {
Channels,
@@ -9,7 +14,7 @@ import {
} from '@tloncorp/shared/dist/urbit/groups';
import _, { get, groupBy } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useNavigate, useParams } from 'react-router';
+import { useParams } from 'react-router';
import { ChatStore, useChatStore } from '@/chat/useChatStore';
import { useNavWithinTab } from '@/components/Sidebar/util';
@@ -19,15 +24,17 @@ import {
RECENT_SORT,
SortMode,
} from '@/constants';
-import {
- useChannel,
- useJoinMutation,
- usePerms,
- useUnreads,
-} from '@/state/channel/channel';
+import { useMarkReadMutation } from '@/state/activity';
+import { useChannel, useJoinMutation, usePerms } from '@/state/channel/channel';
import { useGroup, useRouteGroup } from '@/state/groups';
import { useLastReconnect } from '@/state/local';
import { useNegotiate } from '@/state/negotiation';
+import {
+ Unread,
+ UnreadsStore,
+ useUnreads,
+ useUnreadsStore,
+} from '@/state/unreads';
import useRecentChannel from './useRecentChannel';
import useSidebarSort, {
@@ -37,12 +44,12 @@ import useSidebarSort, {
} from './useSidebarSort';
import { getFirstInline, getFlagParts, getNestShip, nestToFlag } from './utils';
-export function isChannelJoined(nest: string, unreads: Unreads) {
+export function isChannelJoined(nest: string, unreads: Record
) {
const [flag] = nestToFlag(nest);
const { ship } = getFlagParts(flag);
const isChannelHost = window.our === ship;
- return isChannelHost || (nest && nest in unreads);
+ return isChannelHost || (nest && `channel/${nest}` in unreads);
}
export function canReadChannel(
@@ -98,44 +105,45 @@ export function useChannelFlag() {
);
}
-const selChats = (s: ChatStore) => s.chats;
-
-function channelUnread(
- nest: string,
- unreads: Unreads,
- chats: ChatStore['chats']
-) {
- const [app, flag] = nestToFlag(nest);
- const unread = chats[flag]?.unread;
-
- if (app === 'chat') {
- return Boolean(unread && !unread.seen);
+function channelUnread(nest: string, unreads: Record) {
+ const unread = unreads[`channel/${nest}`];
+ if (!unread) {
+ return false;
}
- return (unreads[nest]?.count ?? 0) > 0;
+ return unread.status === 'unread';
}
export function useCheckChannelUnread() {
const unreads = useUnreads();
- const chats = useChatStore(selChats);
+ const isChannelUnread = useCallback(
+ (nest: string) => {
+ return channelUnread(nest, unreads);
+ },
+ [unreads]
+ );
- return useCallback(
+ const getUnread = useCallback(
(nest: string) => {
- if (!unreads || !chats) {
- return false;
+ if (!unreads) {
+ return null;
}
- return channelUnread(nest, unreads, chats);
+ return unreads[`channel/${nest}`];
},
- [unreads, chats]
+ [unreads]
);
+
+ return {
+ isChannelUnread,
+ getUnread,
+ };
}
export function useIsChannelUnread(nest: string) {
const unreads = useUnreads();
- const chats = useChatStore(selChats);
- return channelUnread(nest, unreads, chats);
+ return channelUnread(nest, unreads);
}
export const useIsChannelHost = (flag: string) =>
@@ -244,18 +252,21 @@ export function useChannelSections(groupFlag: string) {
};
}
+const getLoaded = (s: UnreadsStore) => s.loaded;
export function useChannelIsJoined(nest: string) {
+ const loaded = useUnreadsStore(getLoaded);
const unreads = useUnreads();
- return isChannelJoined(nest, unreads);
+ return !loaded || isChannelJoined(nest, unreads);
}
export function useCheckChannelJoined() {
+ const loaded = useUnreadsStore(getLoaded);
const unreads = useUnreads();
return useCallback(
(nest: string) => {
- return isChannelJoined(nest, unreads);
+ return !loaded || isChannelJoined(nest, unreads);
},
- [unreads]
+ [unreads, loaded]
);
}
@@ -391,3 +402,22 @@ export function linkUrlFromContent(content: Story) {
return undefined;
}
+
+export function useMarkChannelRead(nest: string, thread?: MessageKey) {
+ const group = useRouteGroup();
+ const { mutateAsync, ...rest } = useMarkReadMutation();
+ const markRead = useCallback(() => {
+ const source: Source = thread
+ ? {
+ thread: {
+ group,
+ channel: nest,
+ key: thread,
+ },
+ }
+ : { channel: { group, nest } };
+ return mutateAsync({ source });
+ }, [group, nest, thread, mutateAsync]);
+
+ return { markRead, ...rest };
+}
diff --git a/apps/tlon-web/src/logic/useGroupSort.ts b/apps/tlon-web/src/logic/useGroupSort.ts
index cd762652e2..f944e16954 100644
--- a/apps/tlon-web/src/logic/useGroupSort.ts
+++ b/apps/tlon-web/src/logic/useGroupSort.ts
@@ -32,15 +32,7 @@ export default function useGroupSort() {
const accessors: Record string> = {
[ALPHABETICAL_SORT]: (_flag: string, group: Group) =>
get(group, 'meta.title'),
- [RECENT_SORT]: (flag: string, group: Group) => {
- /**
- * Use the latest channel flag associated with the Group; otherwise
- * fallback to the Group flag itself, which won't be in the briefs and
- * thus use INFINITY by default
- */
- const channels = sortChannels(group.channels);
- return channels.length > 0 ? channels[0][0] : flag;
- },
+ [RECENT_SORT]: (flag: string, group: Group) => `group/${flag}`,
};
return sortRecordsBy(
diff --git a/apps/tlon-web/src/logic/useIsGroupUnread.ts b/apps/tlon-web/src/logic/useIsGroupUnread.ts
index 66bfd95ca0..cc034eac79 100644
--- a/apps/tlon-web/src/logic/useIsGroupUnread.ts
+++ b/apps/tlon-web/src/logic/useIsGroupUnread.ts
@@ -1,40 +1,19 @@
import { useCallback } from 'react';
-import { useGroups } from '@/state/groups';
+import { Unread, emptyUnread, useUnreads } from '@/state/unreads';
-import { useCheckChannelUnread } from './channel';
+const defaultUnread = emptyUnread();
-export default function useIsGroupUnread() {
- const groups = useGroups();
- const isChannelUnread = useCheckChannelUnread();
-
- /**
- * A Group is unread if
- * - any of it's Channels have new items in their corresponding unreads
- * - any of its Channels are unread (bin is unread, rope channel matches
- * chFlag)
- */
- const isGroupUnread = useCallback(
- (flag: string) => {
- const group = groups[flag];
- const chNests = group ? Object.keys(group.channels) : [];
-
- return chNests.reduce(
- (memo, nest) => memo || isChannelUnread(nest),
- false
- );
+export default function useGroupUnread() {
+ const unreads = useUnreads();
+ const getGroupUnread = useCallback(
+ (flag: string): Unread => {
+ return unreads?.[`group/${flag}`] || defaultUnread;
},
- [groups, isChannelUnread]
+ [unreads]
);
return {
- isGroupUnread,
+ getGroupUnread,
};
}
-
-export function useIsAnyGroupUnread() {
- const groups = useGroups();
- const { isGroupUnread } = useIsGroupUnread();
- if (!groups) return undefined;
- return Object.keys(groups).some((flag) => isGroupUnread(flag));
-}
diff --git a/apps/tlon-web/src/logic/useMessageSelector.ts b/apps/tlon-web/src/logic/useMessageSelector.ts
index 5ecec173fe..a7d80c7127 100644
--- a/apps/tlon-web/src/logic/useMessageSelector.ts
+++ b/apps/tlon-web/src/logic/useMessageSelector.ts
@@ -8,7 +8,6 @@ import { ShipOption } from '@/components/ShipSelector';
import {
SendMessageVariables,
useCreateMultiDm,
- useDmUnreads,
useMultiDms,
useSendMessage,
} from '@/state/chat';
@@ -16,6 +15,7 @@ import {
useForceNegotiationUpdate,
useNegotiateMulti,
} from '@/state/negotiation';
+import { useUnreads } from '@/state/unreads';
import { createStorageKey, newUv } from './utils';
@@ -30,7 +30,7 @@ export default function useMessageSelector() {
const isMultiDm = ships.length > 1;
const shipValues = useMemo(() => ships.map((o) => o.value), [ships]);
const multiDms = useMultiDms();
- const { data: unreads } = useDmUnreads();
+ const unreads = useUnreads();
const { mutate: sendMessage } = useSendMessage();
const { mutateAsync: createMultiDm } = useCreateMultiDm();
@@ -52,9 +52,9 @@ export default function useMessageSelector() {
}
return (
- Object.entries(unreads).find(([flag, _unread]) => {
+ Object.entries(unreads).find(([source, _unread]) => {
const theShip = ships[0].value;
- const sameDM = theShip === flag;
+ const sameDM = `ship/${theShip}` === source;
return sameDM;
})?.[0] ?? null
);
@@ -74,8 +74,8 @@ export default function useMessageSelector() {
const sameDM =
difference(shipValues, theShips).length === 0 &&
shipValues.length === theShips.length;
- const unread = unreads[key];
- const newUnread = unreads[k];
+ const unread = unreads[`club/${key}`];
+ const newUnread = unreads[`club/${k}`];
const newer =
!unread || (unread && newUnread && newUnread.recency > unread.recency);
if (sameDM && newer) {
diff --git a/apps/tlon-web/src/logic/useMessageSort.ts b/apps/tlon-web/src/logic/useMessageSort.ts
index bc484a6bf3..c7fe5b4f19 100644
--- a/apps/tlon-web/src/logic/useMessageSort.ts
+++ b/apps/tlon-web/src/logic/useMessageSort.ts
@@ -1,7 +1,5 @@
-import { Unread } from '@tloncorp/shared/dist/urbit/channel';
-import { DMUnread } from '@tloncorp/shared/dist/urbit/dms';
-
import { RECENT_SORT } from '@/constants';
+import { Unread } from '@/state/unreads';
import useSidebarSort, { Sorter, useRecentSort } from './useSidebarSort';
@@ -15,12 +13,9 @@ export default function useMessageSort() {
sortOptions,
});
- function sortMessages(unreads: Record) {
- const accessors: Record<
- string,
- (k: string, v: Unread | DMUnread) => string
- > = {
- [RECENT_SORT]: (flag: string, _unread: Unread | DMUnread) => flag,
+ function sortMessages(unreads: Record) {
+ const accessors: Record string> = {
+ [RECENT_SORT]: (flag: string, _unread: Unread) => flag,
};
return sortRecordsBy(
diff --git a/apps/tlon-web/src/logic/useReactQueryScry.ts b/apps/tlon-web/src/logic/useReactQueryScry.ts
index 5bcd640e59..c91e68531a 100644
--- a/apps/tlon-web/src/logic/useReactQueryScry.ts
+++ b/apps/tlon-web/src/logic/useReactQueryScry.ts
@@ -8,25 +8,27 @@ export default function useReactQueryScry({
queryKey,
app,
path,
+ onScry,
priority = 3,
options,
}: {
queryKey: QueryKey;
app: string;
path: string;
+ onScry?: (data: T) => T;
priority?: number;
options?: UseQueryOptions;
}) {
const fetchData = useCallback(
async () =>
- useSchedulerStore.getState().wait(
- async () =>
- api.scry({
- app,
- path,
- }),
- priority
- ),
+ useSchedulerStore.getState().wait(async () => {
+ const result = await api.scry({
+ app,
+ path,
+ });
+
+ return onScry ? onScry(result) : result;
+ }, priority),
[app, path, priority]
);
diff --git a/apps/tlon-web/src/logic/useReactQuerySubscription.tsx b/apps/tlon-web/src/logic/useReactQuerySubscription.tsx
index 77a16949c0..30d9c56edb 100644
--- a/apps/tlon-web/src/logic/useReactQuerySubscription.tsx
+++ b/apps/tlon-web/src/logic/useReactQuerySubscription.tsx
@@ -18,6 +18,7 @@ export default function useReactQuerySubscription({
scryApp = app,
priority = 3,
onEvent,
+ onScry,
options,
}: {
queryKey: QueryKey;
@@ -27,6 +28,7 @@ export default function useReactQuerySubscription({
scryApp?: string;
priority?: number;
onEvent?: (data: Event) => void;
+ onScry?: (data: T) => T;
options?: UseQueryOptions;
}) {
const queryClient = useQueryClient();
@@ -42,11 +44,12 @@ export default function useReactQuerySubscription({
const fetchData = async () =>
useSchedulerStore.getState().wait(async () => {
- console.log('scrying', scryApp, scry);
- return api.scry({
+ const result = await api.scry({
app: scryApp,
path: scry,
});
+
+ return onScry ? onScry(result) : result;
}, priority);
useEffect(() => {
diff --git a/apps/tlon-web/src/logic/useScrollerMessages.ts b/apps/tlon-web/src/logic/useScrollerMessages.ts
index 8d3bee2d89..3cc809f0b7 100644
--- a/apps/tlon-web/src/logic/useScrollerMessages.ts
+++ b/apps/tlon-web/src/logic/useScrollerMessages.ts
@@ -1,3 +1,4 @@
+import { MessageKey } from '@tloncorp/shared/dist/urbit/activity';
import { Post, Reply } from '@tloncorp/shared/dist/urbit/channel';
import { Writ } from '@tloncorp/shared/dist/urbit/dms';
import { daToUnix } from '@urbit/api';
@@ -79,7 +80,7 @@ const emptyWrit = {
},
};
-export type ChatMessageListItemData = {
+export type MessageListItemData = {
writ: Writ | Post | Reply;
type: 'message';
time: bigInt.BigInteger;
@@ -89,13 +90,16 @@ export type ChatMessageListItemData = {
isLast: boolean;
isLinked: boolean;
hideReplies: boolean;
+ parent?: MessageKey;
};
function useMessageItems({
messages,
+ parent,
}: {
scrollTo?: bigInt.BigInteger;
messages: WritArray;
+ parent?: MessageKey;
}): [
bigInt.BigInteger[],
{
@@ -138,6 +142,7 @@ function useMessageItems({
time: key,
newAuthor,
newDay,
+ parent,
};
}
@@ -163,28 +168,34 @@ function useMessageItems({
export function useMessageData({
whom,
scrollTo,
+ parent,
messages,
replying,
}: {
whom: string;
+ parent?: MessageKey;
scrollTo?: bigInt.BigInteger;
messages: WritArray;
replying: boolean;
}) {
const [activeMessageKeys, messageEntries, activeMessages] = useMessageItems({
messages,
+ parent,
});
- const activeMessageEntries: ChatMessageListItemData[] = useMemo(
+ const activeMessageEntries: MessageListItemData[] = useMemo(
() =>
- messageEntries.map((props, index) => ({
- type: 'message',
- whom,
- isLast: index === messageEntries.length - 1,
- isLinked: !!scrollTo && (props.time?.eq(scrollTo) ?? false),
- hideReplies: replying,
- ...props,
- })),
+ messageEntries.map(
+ (props, index) =>
+ ({
+ type: 'message',
+ whom,
+ isLast: index === messageEntries.length - 1,
+ isLinked: !!scrollTo && (props.time?.eq(scrollTo) ?? false),
+ hideReplies: replying,
+ ...props,
+ }) as MessageListItemData
+ ),
[whom, scrollTo, messageEntries, replying]
);
diff --git a/apps/tlon-web/src/logic/useSidebarSort.ts b/apps/tlon-web/src/logic/useSidebarSort.ts
index 36107b9485..79d1ea0541 100644
--- a/apps/tlon-web/src/logic/useSidebarSort.ts
+++ b/apps/tlon-web/src/logic/useSidebarSort.ts
@@ -1,17 +1,13 @@
-import { Unreads } from '@tloncorp/shared/dist/urbit/channel';
-import { DMUnreads } from '@tloncorp/shared/dist/urbit/dms';
+import _ from 'lodash';
import { useCallback, useMemo } from 'react';
import { DEFAULT_SORT, RECENT_SORT, SortMode } from '@/constants';
-import { useUnreads } from '@/state/channel/channel';
-import { useDmUnreads } from '@/state/chat';
import {
useGroupSideBarSort,
usePutEntryMutation,
useSideBarSortMode,
} from '@/state/settings';
-
-import { whomIsDm, whomIsMultiDm } from './utils';
+import { useUnreads } from '@/state/unreads';
export interface Sorter {
(a: string, b: string): number;
@@ -25,51 +21,20 @@ interface UseSidebarSort {
export const sortAlphabetical = (aNest: string, bNest: string) =>
aNest.localeCompare(bNest);
-interface MergeUnreadsAccumulatorType {
- [key: string]: number;
-}
-
export function useRecentSort() {
- const channelUnreads = useUnreads();
- const { data: dmUnreads } = useDmUnreads();
- // pre-compute unreads before sorting
- const processedUnreads = useMemo(() => {
- const mergeUnreads = (unreads: DMUnreads | Unreads) =>
- Object.entries(unreads).reduce(
- (acc, [nest, { recency }]) => {
- // using a param re-assign is much faster than making a copy of an object.
- // eslint-disable-next-line no-param-reassign
- acc[nest] = recency ?? Number.NEGATIVE_INFINITY;
- return acc;
- },
- {}
- );
-
- return {
- dmUnreads: mergeUnreads(dmUnreads),
- channelUnreads: mergeUnreads(channelUnreads),
- };
- }, [dmUnreads, channelUnreads]);
+ const unreads = useUnreads();
+ const recencyMap = useMemo(() => {
+ return _.mapValues(unreads, ({ recency }) => recency);
+ }, [unreads]);
const sortRecent = useCallback(
- (aNest: string, bNest: string) => {
- const aUnreads =
- whomIsDm(aNest) || whomIsMultiDm(aNest)
- ? processedUnreads.dmUnreads
- : processedUnreads.channelUnreads;
- // if the nest is not in the unreads, default to negative infinity
- const aLast = aUnreads[aNest] ?? Number.NEGATIVE_INFINITY;
-
- const bUnreads =
- whomIsDm(bNest) || whomIsMultiDm(bNest)
- ? processedUnreads.dmUnreads
- : processedUnreads.channelUnreads;
- // if the nest is not in the unreads, default to negative infinity
- const bLast = bUnreads[bNest] ?? Number.NEGATIVE_INFINITY;
+ (aIndex: string, bIndex: string) => {
+ const aLast = recencyMap[aIndex] ?? Number.NEGATIVE_INFINITY;
+ const bLast = recencyMap[bIndex] ?? Number.NEGATIVE_INFINITY;
return Math.sign(aLast - bLast);
},
- [processedUnreads]
+ [recencyMap]
);
return sortRecent;
diff --git a/apps/tlon-web/src/logic/utils.ts b/apps/tlon-web/src/logic/utils.ts
index a4a74536df..e2cd38f329 100644
--- a/apps/tlon-web/src/logic/utils.ts
+++ b/apps/tlon-web/src/logic/utils.ts
@@ -1,8 +1,10 @@
+import { MessageKey } from '@tloncorp/shared/dist/urbit/activity';
import {
CacheId,
ChatStory,
Cite,
Listing,
+ Post,
Story,
Verse,
VerseBlock,
@@ -32,9 +34,8 @@ import {
DocketHref,
Treaty,
udToDec,
- unixToDa,
} from '@urbit/api';
-import { formatUv } from '@urbit/aura';
+import { formatUd, formatUv, unixToDa } from '@urbit/aura';
import anyAscii from 'any-ascii';
import bigInt, { BigInteger } from 'big-integer';
import { hsla, parseToHsla, parseToRgba } from 'color2k';
@@ -103,7 +104,7 @@ export function useObjectChangeLogging(
const lastValues = useRef(o);
Object.entries(o).forEach(([k, v]) => {
if (v !== lastValues.current[k]) {
- logger.log('[change]', k);
+ logger.log('[change]', k, 'old:', lastValues.current[k], 'new:', v);
lastValues.current[k] = v;
}
});
@@ -1296,3 +1297,10 @@ export function cacheIdFromString(str: string): CacheId {
sent: parseInt(udToDec(sentStr), 10),
};
}
+
+export function getMessageKey(post: Post): MessageKey {
+ return {
+ id: `${post.essay.author}/${formatUd(unixToDa(post.essay.sent))}`,
+ time: formatUd(bigInt(post.seal.id)),
+ };
+}
diff --git a/apps/tlon-web/src/mocks/chat.ts b/apps/tlon-web/src/mocks/chat.ts
index 7d03dae717..06d35b7406 100644
--- a/apps/tlon-web/src/mocks/chat.ts
+++ b/apps/tlon-web/src/mocks/chat.ts
@@ -1,11 +1,11 @@
import { faker } from '@faker-js/faker';
+import { Activity } from '@tloncorp/shared/dist/urbit/activity';
import {
Post,
Posts,
Story,
storyFromChatStory,
} from '@tloncorp/shared/dist/urbit/channel';
-import { DMUnreads } from '@tloncorp/shared/dist/urbit/dms';
import { decToUd, unixToDa } from '@urbit/api';
import { subDays, subMinutes } from 'date-fns';
import _ from 'lodash';
@@ -135,54 +135,62 @@ export const makeFakeChatWrits = (offset: number) => {
export const chatKeys = ['~zod/test'];
-export const dmList: DMUnreads = {
+export const dmList: Activity = {
'~fabled-faster': {
recency: 0,
count: 0,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~nocsyx-lassul': {
recency: 1652302200000,
count: 3,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~fallyn-balfus': {
recency: 0,
count: 0,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~finned-palmer': {
recency: 1652302200000,
count: 2,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~datder-sonnet': {
recency: 1652302200000,
count: 1,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~hastuc-dibtux': {
recency: 0,
count: 0,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~rilfun-lidlen': {
recency: 0,
count: 0,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
'~mister-dister-dozzod-dozzod': {
recency: 0,
count: 0,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
},
};
diff --git a/apps/tlon-web/src/mocks/handlers.ts b/apps/tlon-web/src/mocks/handlers.ts
index 4e98ce8779..b28b5f8286 100644
--- a/apps/tlon-web/src/mocks/handlers.ts
+++ b/apps/tlon-web/src/mocks/handlers.ts
@@ -429,8 +429,9 @@ const dms: Handler[] = [
const unread = {
recency: 1652302200000,
count: 1,
+ notify: false,
unread: null,
- threads: {},
+ children: [],
};
dmList[req.json.ship] = unread;
diff --git a/apps/tlon-web/src/replies/ReplyMessage.tsx b/apps/tlon-web/src/replies/ReplyMessage.tsx
index 061ac81bc4..0514dba570 100644
--- a/apps/tlon-web/src/replies/ReplyMessage.tsx
+++ b/apps/tlon-web/src/replies/ReplyMessage.tsx
@@ -1,14 +1,15 @@
import { Editor } from '@tiptap/core';
+import { MessageKey } from '@tloncorp/shared/dist/urbit';
+import { getThreadKey } from '@tloncorp/shared/dist/urbit/activity';
import {
Reply,
Story,
- Unread,
constructStory,
emptyReply,
} from '@tloncorp/shared/dist/urbit/channel';
-import { DMUnread } from '@tloncorp/shared/dist/urbit/dms';
import { daToUnix } from '@urbit/api';
-import { BigInteger } from 'big-integer';
+import { formatUd, unixToDa } from '@urbit/aura';
+import bigInt, { BigInteger } from 'big-integer';
import cn from 'classnames';
import { format } from 'date-fns';
import debounce from 'lodash/debounce';
@@ -36,14 +37,14 @@ import {
import MessageEditor, { useMessageEditor } from '@/components/MessageEditor';
import CheckIcon from '@/components/icons/CheckIcon';
import DoubleCaretRightIcon from '@/components/icons/DoubleCaretRightIcon';
+import { useMarkChannelRead } from '@/logic/channel';
import { JSONToInlines, diaryMixedToJSON } from '@/logic/tiptap';
import useLongPress from '@/logic/useLongPress';
import { useIsMobile } from '@/logic/useMedia';
-import { nestToFlag, useIsDmOrMultiDm, whomIsNest } from '@/logic/utils';
+import { useIsDmOrMultiDm, whomIsFlag, whomIsNest } from '@/logic/utils';
import {
useEditReplyMutation,
useIsEdited,
- useMarkReadMutation,
usePostToggler,
useTrackedPostStatus,
} from '@/state/channel/channel';
@@ -51,13 +52,16 @@ import {
useMarkDmReadMutation,
useMessageToggler,
useTrackedMessageStatus,
+ useWrit,
} from '@/state/chat';
+import { useUnread, useUnreadsStore } from '@/state/unreads';
import ReplyMessageOptions from './ReplyMessageOptions';
import ReplyReactions from './ReplyReactions/ReplyReactions';
export interface ReplyMessageProps {
whom: string;
+ parent: MessageKey;
time: BigInteger;
reply: Reply;
newAuthor?: boolean;
@@ -69,19 +73,6 @@ export interface ReplyMessageProps {
showReply?: boolean;
}
-function amUnread(unread?: Unread | DMUnread, parent?: string, id?: string) {
- if (!unread || !parent || !id) {
- return false;
- }
-
- const thread = unread.threads[parent];
- if (typeof thread === 'object') {
- return thread.id === id;
- }
-
- return thread === id;
-}
-
const mergeRefs =
(...refs: any[]) =>
(node: any) => {
@@ -111,6 +102,7 @@ const ReplyMessage = React.memo<
(
{
whom,
+ parent,
time,
reply,
newAuthor = false,
@@ -134,14 +126,25 @@ const ReplyMessage = React.memo<
const isThreadOp = seal['parent-id'] === seal.id;
const isMobile = useIsMobile();
const isThreadOnMobile = isMobile;
- const chatInfo = useChatInfo(whom);
+ const id = !whomIsFlag(whom)
+ ? seal.id
+ : `${memo.author}/${formatUd(bigInt(seal.id))}`;
+ const threadKey = getThreadKey(
+ whom,
+ !whomIsFlag(whom) ? seal.id : parent.time
+ );
+ const chatInfo = useChatInfo(`${whom}/${parent.id}`);
+ const unread = useUnread(threadKey);
const isDMOrMultiDM = useIsDmOrMultiDm(whom);
- const unread = chatInfo?.unread;
- const isUnread = amUnread(unread?.unread, seal['parent-id'], seal.id);
+ const isUnread =
+ unread?.status !== 'read' && unread?.lastUnread?.id === id;
const { hovering, setHovering } = useChatHovering(whom, seal.id);
const { open: pickerOpen } = useChatDialog(whom, seal.id, 'picker');
- const { mutate: markChatRead } = useMarkReadMutation();
- const { mutate: markDmRead } = useMarkDmReadMutation();
+ const { markRead: markChannelRead } = useMarkChannelRead(
+ `chat/${whom}`,
+ parent
+ );
+ const { markDmRead } = useMarkDmReadMutation(whom, parent);
const { mutate: editReply } = useEditReplyMutation();
const { isHidden: isMessageHidden } = useMessageToggler(seal.id);
const { isHidden: isPostHidden } = usePostToggler(seal.id);
@@ -158,14 +161,14 @@ const ReplyMessage = React.memo<
return;
}
- const { unread: brief, seen } = unread;
+ const unseen = unread.status === 'unread';
/* the first fire of this function
which we don't to do anything with. */
- if (!inView && !seen) {
+ if (!inView && unseen) {
return;
}
- const { seen: markSeen, delayedRead } = useChatStore.getState();
+ const { seen: markSeen, delayedRead } = useUnreadsStore.getState();
/* once the unseen marker comes into view we need to mark it
as seen and start a timer to mark it read so it goes away.
@@ -173,18 +176,18 @@ const ReplyMessage = React.memo<
doing so. we don't want to accidentally clear unreads when
the state has changed
*/
- if (inView && isUnread && !seen) {
- markSeen(whom);
- delayedRead(whom, () => {
+ if (inView && isUnread && unseen) {
+ markSeen(threadKey);
+ delayedRead(threadKey, () => {
if (isDMOrMultiDM) {
- markDmRead({ whom });
+ markDmRead();
} else {
- markChatRead({ nest: `chat/${whom}` });
+ markChannelRead();
}
});
}
},
- [unread, whom, isDMOrMultiDM, markChatRead, markDmRead, isUnread]
+ [unread, whom, isDMOrMultiDM, markChannelRead, markDmRead, isUnread]
),
});
@@ -345,11 +348,7 @@ const ReplyMessage = React.memo<
{...handlers}
>
{unread && isUnread ? (
-
+
) : null}
{newDay && !isUnread ? : null}
{newAuthor ? (
diff --git a/apps/tlon-web/src/replies/replies.ts b/apps/tlon-web/src/replies/replies.ts
index 57a56dcf9f..d068e734f1 100644
--- a/apps/tlon-web/src/replies/replies.ts
+++ b/apps/tlon-web/src/replies/replies.ts
@@ -1,16 +1,15 @@
-import {
- Kind,
- Reply,
- ReplyTuple,
- Unread,
-} from '@tloncorp/shared/dist/urbit/channel';
-import { daToUnix } from '@urbit/aura';
+import { MessageKey } from '@tloncorp/shared/dist/urbit/activity';
+import { Kind, Reply, ReplyTuple } from '@tloncorp/shared/dist/urbit/channel';
+import { daToUnix, parseUd } from '@urbit/aura';
import bigInt, { BigInteger } from 'big-integer';
import { isSameDay } from 'date-fns';
+import { Unread } from '@/state/unreads';
+
export interface ReplyProps {
han: Kind;
noteId: string;
+ parent: MessageKey;
time: BigInteger;
reply: Reply;
newAuthor: boolean;
@@ -43,9 +42,9 @@ export function setNewDaysForReplies(
}
export function groupReplies(
- noteId: string,
+ parent: MessageKey,
replies: ReplyTuple[],
- unread: Unread
+ unread?: Unread
) {
const grouped: Record = {};
let currentTime: string;
@@ -61,7 +60,7 @@ export function groupReplies(
const newAuthor =
prev && prev[1] !== null ? author !== prev[1].memo.author : true;
const unreadUnread =
- unread && unread.unread?.id === q.seal.id ? unread : undefined;
+ unread && unread.lastUnread?.id === q.seal.id ? unread : undefined;
if (newAuthor) {
currentTime = time;
@@ -74,11 +73,12 @@ export function groupReplies(
grouped[currentTime].push({
han: 'diary',
time: t,
+ parent,
reply: q,
newAuthor,
- noteId,
+ noteId: parseUd(parent.time).toString(),
newDay: false,
- unreadCount: unreadUnread && unread.count,
+ unreadCount: (unreadUnread && unread?.count) || 0,
});
});
diff --git a/apps/tlon-web/src/state/activity.ts b/apps/tlon-web/src/state/activity.ts
new file mode 100644
index 0000000000..da77603b23
--- /dev/null
+++ b/apps/tlon-web/src/state/activity.ts
@@ -0,0 +1,264 @@
+import { useMutation } from '@tanstack/react-query';
+import {
+ Activity,
+ ActivityAction,
+ ActivityDeleteUpdate,
+ ActivityReadUpdate,
+ ActivitySummary,
+ ActivityUpdate,
+ ActivityVolumeUpdate,
+ ReadAction,
+ Source,
+ VolumeMap,
+ VolumeSettings,
+ sourceToString,
+ stripPrefixes,
+} from '@tloncorp/shared/dist/urbit/activity';
+import _ from 'lodash';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import api from '@/api';
+import { useChatStore } from '@/chat/useChatStore';
+import useReactQueryScry from '@/logic/useReactQueryScry';
+import { createDevLogger, nestToFlag } from '@/logic/utils';
+import queryClient from '@/queryClient';
+
+import { useUnreadsStore } from './unreads';
+
+const actLogger = createDevLogger('activity', false);
+
+export const unreadsKey = ['activity', 'unreads'];
+export const volumeKey = ['activity', 'volume'];
+
+export function activityAction(action: ActivityAction) {
+ return {
+ app: 'activity',
+ mark: 'activity-action',
+ json: action,
+ };
+}
+
+function activityReadUpdates(events: ActivityReadUpdate[]) {
+ const unreads: Record = {};
+
+ events.forEach((event) => {
+ const { source, activity } = event.read;
+ if ('base' in source) {
+ return;
+ }
+
+ unreads[sourceToString(source)] = activity;
+ });
+
+ return unreads;
+}
+
+function activityVolumeUpdates(events: ActivityVolumeUpdate[]) {
+ return events.reduce((acc, event) => {
+ const { source, volume } = event.adjust;
+ if (volume === null) {
+ return acc;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ acc[sourceToString(source)] = volume;
+ return acc;
+ }, {} as VolumeSettings);
+}
+
+function processActivityUpdates(updates: ActivityUpdate[]) {
+ const readEvents = updates.filter((e) => 'read' in e) as ActivityReadUpdate[];
+ actLogger.log('checking read events', readEvents);
+ if (readEvents.length > 0) {
+ const unreads = activityReadUpdates(readEvents);
+ useUnreadsStore.getState().update(unreads);
+ queryClient.setQueryData(unreadsKey, (d: Activity | undefined) => {
+ if (d === undefined) {
+ return undefined;
+ }
+
+ return {
+ ...d,
+ ...unreads,
+ };
+ });
+ }
+
+ const adjustEvents = updates.filter(
+ (e) => 'adjust' in e
+ ) as ActivityVolumeUpdate[];
+ if (adjustEvents.length > 0) {
+ const volumes = activityVolumeUpdates(adjustEvents);
+ console.log('new volumes', volumes);
+ queryClient.setQueryData(volumeKey, (v) =>
+ v === undefined ? undefined : { ...v, ...volumes }
+ );
+ }
+
+ const delEvents = updates.filter((e) => 'del' in e) as ActivityDeleteUpdate[];
+ if (delEvents.length > 0) {
+ queryClient.setQueryData(unreadsKey, (unreads: Activity | undefined) => {
+ if (unreads === undefined) {
+ return undefined;
+ }
+
+ return delEvents.reduce((acc, event) => {
+ const source = sourceToString(event.del, true);
+ delete acc[source];
+ return acc;
+ }, unreads);
+ });
+ }
+}
+
+export function useActivityFirehose() {
+ const [eventQueue, setEventQueue] = useState([]);
+ const eventHandler = useCallback((event: ActivityUpdate) => {
+ actLogger.log('received activity', event);
+ setEventQueue((prev) => [...prev, event]);
+ }, []);
+ actLogger.log('events', eventQueue);
+
+ useEffect(() => {
+ api.subscribe({
+ app: 'activity',
+ path: '/',
+ event: eventHandler,
+ });
+ }, [eventHandler]);
+
+ const processQueue = useRef(
+ _.debounce(
+ (events: ActivityUpdate[]) => {
+ actLogger.log('processing events', events);
+ processActivityUpdates(events);
+ setEventQueue([]);
+ },
+ 300,
+ { leading: true, trailing: true }
+ )
+ );
+
+ useEffect(() => {
+ actLogger.log('checking queue', eventQueue.length);
+ if (eventQueue.length === 0) {
+ return;
+ }
+
+ actLogger.log('attempting to process queue', eventQueue.length);
+ processQueue.current(eventQueue);
+ }, [eventQueue]);
+}
+
+export function useMarkReadMutation() {
+ const mutationFn = async (variables: {
+ source: Source;
+ action?: ReadAction;
+ }) => {
+ await api.poke(
+ activityAction({
+ read: {
+ source: variables.source,
+ action: variables.action || { all: null },
+ },
+ })
+ );
+ };
+
+ return useMutation({
+ mutationFn,
+ onSuccess: () => {
+ queryClient.invalidateQueries(unreadsKey, undefined, {
+ cancelRefetch: true,
+ });
+ },
+ });
+}
+
+const emptyUnreads: Activity = {};
+export function useUnreads(): Activity {
+ const { data, ...rest } = useReactQueryScry({
+ queryKey: unreadsKey,
+ app: 'activity',
+ path: '/activity',
+ onScry: stripPrefixes,
+ });
+
+ if (rest.isLoading || rest.isError || data === undefined) {
+ return emptyUnreads;
+ }
+
+ return data as Activity;
+}
+
+const emptySettings: VolumeSettings = {};
+export function useVolumeSettings() {
+ const { data, ...rest } = useReactQueryScry({
+ queryKey: volumeKey,
+ app: 'activity',
+ path: '/volume-settings',
+ options: {
+ keepPreviousData: true,
+ },
+ });
+
+ if (rest.isLoading || rest.isError || data === undefined) {
+ return {
+ ...rest,
+ data: emptySettings,
+ };
+ }
+
+ return {
+ ...rest,
+ data,
+ };
+}
+
+export function useVolume(source?: Source) {
+ const { data, ...rest } = useVolumeSettings();
+ if (data === undefined || source === undefined) {
+ return {
+ ...rest,
+ volume: 'default',
+ };
+ }
+
+ return {
+ ...rest,
+ volume: data[sourceToString(source)],
+ };
+}
+
+export function useVolumeAdjustMutation() {
+ return useMutation({
+ mutationFn: async (variables: {
+ source: Source;
+ volume: VolumeMap | null;
+ }) => {
+ return api.poke(
+ activityAction({
+ adjust: variables,
+ })
+ );
+ },
+ onMutate: async (variables) => {
+ const current = queryClient.getQueryData(volumeKey);
+ queryClient.setQueryData(volumeKey, (v) => {
+ if (v === undefined) {
+ return undefined;
+ }
+
+ return {
+ ...v,
+ [sourceToString(variables.source)]: variables.volume,
+ };
+ });
+
+ return current;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries(volumeKey);
+ },
+ });
+}
diff --git a/apps/tlon-web/src/state/bootstrap.ts b/apps/tlon-web/src/state/bootstrap.ts
index 67a53fde59..7a66f66c0b 100644
--- a/apps/tlon-web/src/state/bootstrap.ts
+++ b/apps/tlon-web/src/state/bootstrap.ts
@@ -3,10 +3,10 @@ import Urbit from '@urbit/http-api';
import _ from 'lodash';
import api from '@/api';
-import { useChatStore } from '@/chat/useChatStore';
import { asyncWithDefault } from '@/logic/utils';
import queryClient from '@/queryClient';
+import { unreadsKey } from './activity';
import { initializeChat } from './chat';
import useContactState from './contact';
import useDocketState from './docket';
@@ -17,29 +17,29 @@ import usePalsState from './pals';
import { pinsKey } from './pins';
import useSchedulerStore from './scheduler';
import { useStorage } from './storage';
+import { useUnreadsStore } from './unreads';
const emptyGroupsInit: GroupsInit = {
groups: {},
gangs: {},
channels: {},
- unreads: {},
+ activity: {},
pins: [],
chat: {
dms: [],
clubs: {},
- unreads: {},
invited: [],
},
};
async function startGroups() {
// make sure if this errors we don't kill the entire app
- const { channels, unreads, groups, gangs, pins, chat } =
+ const { channels, groups, gangs, pins, chat, activity } =
await asyncWithDefault(
() =>
api.scry({
app: 'groups-ui',
- path: '/v1/init',
+ path: '/v2/init',
}),
emptyGroupsInit
);
@@ -47,17 +47,11 @@ async function startGroups() {
queryClient.setQueryData(['groups'], groups);
queryClient.setQueryData(['gangs'], gangs);
queryClient.setQueryData(['channels'], channels);
- queryClient.setQueryData(['unreads'], unreads);
queryClient.setQueryData(pinsKey(), pins);
initializeChat(chat);
- // make sure we remove the app part from the nest before handing it over
- useChatStore.getState().update(
- _.mapKeys(
- _.pickBy(unreads, (v, k) => k.startsWith('chat')),
- (v, k) => k.replace(/\w*\//, '')
- )
- );
+ useUnreadsStore.getState().update(activity);
+ queryClient.setQueryData(unreadsKey, activity);
}
type Bootstrap = 'initial' | 'reset' | 'full-reset';
diff --git a/apps/tlon-web/src/state/channel/channel.ts b/apps/tlon-web/src/state/channel/channel.ts
index 31dfd539e1..9fdfdc7c1e 100644
--- a/apps/tlon-web/src/state/channel/channel.ts
+++ b/apps/tlon-web/src/state/channel/channel.ts
@@ -31,16 +31,10 @@ import {
Said,
SortMode,
TogglePost,
- UnreadUpdate,
- Unreads,
newChatMap,
newPostTupleArray,
} from '@tloncorp/shared/dist/urbit/channel';
-import {
- PagedWrits,
- Writ,
- newWritTupleArray,
-} from '@tloncorp/shared/dist/urbit/dms';
+import { PagedWrits, Writ } from '@tloncorp/shared/dist/urbit/dms';
import { Flag } from '@tloncorp/shared/dist/urbit/hark';
import { daToUnix, decToUd, udToDec, unixToDa } from '@urbit/api';
import { Poke } from '@urbit/http-api';
@@ -64,16 +58,20 @@ import {
cacheIdFromString,
cacheIdToString,
checkNest,
+ createDevLogger,
nestToFlag,
stringToTa,
whomIsFlag,
} from '@/logic/utils';
import queryClient from '@/queryClient';
-import ChatQueryKeys from '../chat/keys';
+import { unreadsKey } from '../activity';
+import { useUnreads } from '../unreads';
import { channelKey, infinitePostsKey, postKey } from './keys';
import shouldAddPostToCache from './util';
+const chLogger = createDevLogger('channels-update', false);
+
const POST_PAGE_SIZE = isNativeApp()
? STANDARD_MESSAGE_FETCH_PAGE_SIZE
: LARGE_MESSAGE_FETCH_PAGE_SIZE;
@@ -1139,6 +1137,7 @@ export function useChannelsFirehose() {
}, []);
const eventHandler = useCallback((event: ChannelsSubscribeResponse) => {
+ chLogger.log('received channel update', event);
setEventQueue((prev) => [...prev, event]);
}, []);
@@ -1153,7 +1152,9 @@ export function useChannelsFirehose() {
const processQueue = useRef(
_.debounce(
(events: ChannelsSubscribeResponse[]) => {
+ chLogger.log('processing channel queue', events);
eventProcessor(events);
+ setEventQueue([]);
},
300,
{ leading: true, trailing: true }
@@ -1161,10 +1162,12 @@ export function useChannelsFirehose() {
);
useEffect(() => {
+ chLogger.log('checking channel queue', eventQueue.length);
if (eventQueue.length === 0) {
return;
}
+ chLogger.log('attempting to process channel queue', eventQueue.length);
processQueue.current(eventQueue);
}, [eventQueue]);
}
@@ -1344,79 +1347,6 @@ export function useReply(
}, [post, replyId]);
}
-export function useMarkReadMutation() {
- const mutationFn = async (variables: { nest: Nest }) => {
- checkNest(variables.nest);
-
- await api.poke(channelAction(variables.nest, { read: null }));
- };
-
- return useMutation({
- mutationFn,
- onSuccess: () => {
- queryClient.invalidateQueries(['unreads']);
- },
- });
-}
-
-const emptyUnreads: Unreads = {};
-export function useUnreads(): Unreads {
- const { mutate: markRead } = useMarkReadMutation();
- const invalidate = useRef(
- _.debounce(
- () => {
- queryClient.invalidateQueries({
- queryKey: ['unreads'],
- refetchType: 'none',
- });
- },
- 300,
- { leading: true, trailing: true }
- )
- );
-
- const eventHandler = (event: UnreadUpdate) => {
- const { nest, unread } = event;
-
- if (unread !== null) {
- const [app, flag] = nestToFlag(nest);
-
- if (app === 'chat') {
- useChatStore
- .getState()
- .handleUnread(flag, unread, () => markRead({ nest: `chat/${flag}` }));
- }
-
- queryClient.setQueryData(['unreads'], (d: Unreads | undefined) => {
- if (d === undefined) {
- return undefined;
- }
-
- const newUnreads = { ...d };
- newUnreads[event.nest] = unread;
-
- return newUnreads;
- });
- }
-
- invalidate.current();
- };
-
- const { data, ...rest } = useReactQuerySubscription({
- queryKey: ['unreads'],
- app: 'channels',
- path: '/unreads',
- scry: '/unreads',
- onEvent: eventHandler,
- });
-
- if (rest.isLoading || rest.isError || data === undefined) {
- return emptyUnreads;
- }
-
- return data as Unreads;
-}
-
export function useChatStoreChannelUnreads() {
const chats = useChatStore((s) => s.chats);
@@ -1440,15 +1370,7 @@ export function useIsJoined(nest: Nest) {
checkNest(nest);
const unreads = useUnreads();
- return Object.keys(unreads).includes(nest);
-}
-
-export function useUnread(nest: Nest) {
- checkNest(nest);
-
- const unreads = useUnreads();
-
- return unreads[nest];
+ return Object.keys(unreads).includes(`channel/${nest}`);
}
export function useChats(): Channels {
@@ -1564,7 +1486,7 @@ export function useLeaveMutation() {
onMutate: async (variables) => {
const [han, flag] = nestToFlag(variables.nest);
await queryClient.cancelQueries(channelKey());
- await queryClient.cancelQueries(['unreads']);
+ await queryClient.cancelQueries(unreadsKey);
await queryClient.cancelQueries([han, 'perms', flag]);
await queryClient.cancelQueries([han, 'posts', flag]);
queryClient.removeQueries([han, 'perms', flag]);
@@ -1572,7 +1494,7 @@ export function useLeaveMutation() {
},
onSettled: async (_data, _error) => {
await queryClient.invalidateQueries(channelKey());
- await queryClient.invalidateQueries(['unreads']);
+ await queryClient.invalidateQueries(unreadsKey);
},
});
}
diff --git a/apps/tlon-web/src/state/chat/chat.ts b/apps/tlon-web/src/state/chat/chat.ts
index a3bd848cfc..e44435ed3a 100644
--- a/apps/tlon-web/src/state/chat/chat.ts
+++ b/apps/tlon-web/src/state/chat/chat.ts
@@ -1,4 +1,9 @@
import { QueryKey, useInfiniteQuery, useMutation } from '@tanstack/react-query';
+import {
+ MessageKey,
+ Source,
+ getKey,
+} from '@tloncorp/shared/dist/urbit/activity';
import {
CacheId,
ChannelsAction,
@@ -14,7 +19,6 @@ import {
ClubDelta,
Clubs,
DMInit,
- DMUnreadUpdate,
DMUnreads,
DmAction,
HiddenMessages,
@@ -30,7 +34,6 @@ import {
WritResponse,
WritResponseDelta,
WritSeal,
- WritTuple,
Writs,
newWritTupleArray,
} from '@tloncorp/shared/dist/urbit/dms';
@@ -44,7 +47,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import create from 'zustand';
import api from '@/api';
-import { ChatStore, useChatInfo, useChatStore } from '@/chat/useChatStore';
+import { ChatStore, useChatStore } from '@/chat/useChatStore';
import {
LARGE_MESSAGE_FETCH_PAGE_SIZE,
STANDARD_MESSAGE_FETCH_PAGE_SIZE,
@@ -52,10 +55,12 @@ import {
import { isNativeApp } from '@/logic/native';
import useReactQueryScry from '@/logic/useReactQueryScry';
import useReactQuerySubscription from '@/logic/useReactQuerySubscription';
-import { whomIsDm } from '@/logic/utils';
+import { whomIsDm, whomIsNest } from '@/logic/utils';
import queryClient from '@/queryClient';
+import { unreadsKey, useMarkReadMutation } from '../activity';
import { PostStatus, TrackedPost } from '../channel/channel';
+import { useUnread } from '../unreads';
import ChatKeys from './keys';
import emptyMultiDm, {
appendWritToLastPage,
@@ -176,13 +181,10 @@ function resolveHiddenMessages(toggle: ToggleMessage) {
};
}
-export function initializeChat({ dms, clubs, invited, unreads }: DMInit) {
+export function initializeChat({ dms, clubs, invited }: DMInit) {
queryClient.setQueryData(['dms', 'dms'], () => dms || []);
queryClient.setQueryData(['dms', 'multi'], () => clubs || {});
queryClient.setQueryData(ChatKeys.pending(), () => invited || []);
- queryClient.setQueryData(ChatKeys.unreads(), () => unreads || {});
-
- useChatStore.getState().update(unreads);
}
interface PageParam {
@@ -664,98 +666,24 @@ export function useDmIsPending(ship: string) {
return pending.includes(ship);
}
-export function useMarkDmReadMutation() {
- const mutationFn = async (variables: { whom: string }) => {
- const { whom } = variables;
- await api.poke({
- app: 'chat',
- mark: 'chat-remark-action',
- json: {
- whom,
- diff: { read: null },
- },
- });
- };
-
- return useMutation({
- mutationFn,
- });
-}
-
-export function useDmUnreads() {
- const dmUnreadsKey = ChatKeys.unreads();
- const { mutate: markDmRead } = useMarkDmReadMutation();
- const { pending } = usePendingDms();
- const pendingRef = useRef(pending);
- pendingRef.current = pending;
-
- const invalidate = useRef(
- _.debounce(
- () => {
- queryClient.invalidateQueries({
- queryKey: dmUnreadsKey,
- refetchType: 'none',
- });
- },
- 300,
- { leading: true, trailing: true }
- )
- );
-
- const eventHandler = (event: DMUnreadUpdate) => {
- invalidate.current();
- const { whom, unread } = event;
-
- // we don't get an update on the pending subscription when rsvps are accepted
- // but we do get an unread notification, so we use it here for invalidation
- if (pendingRef.current.includes(whom)) {
- queryClient.invalidateQueries(ChatKeys.pending());
- }
-
- if (unread !== null) {
- useChatStore
- .getState()
- .handleUnread(whom, unread, () => markDmRead({ whom }));
- }
-
- queryClient.setQueryData(dmUnreadsKey, (d: DMUnreads | undefined) => {
- if (d === undefined) {
- return undefined;
- }
-
- const newUnreads = { ...d };
- newUnreads[event.whom] = unread;
-
- return newUnreads;
+export function useMarkDmReadMutation(whom: string, thread?: MessageKey) {
+ const { mutateAsync, ...rest } = useMarkReadMutation();
+ const markDmRead = useCallback(() => {
+ const whomObj = whomIsDm(whom) ? { ship: whom } : { club: whom };
+ const source: Source = thread
+ ? { 'dm-thread': { whom: whomObj, key: thread } }
+ : { dm: whomObj };
+ return mutateAsync({
+ source,
});
- };
-
- const { data, ...query } = useReactQuerySubscription<
- DMUnreads,
- DMUnreadUpdate
- >({
- queryKey: dmUnreadsKey,
- app: 'chat',
- path: '/unreads',
- scry: '/unreads',
- onEvent: eventHandler,
- options: {
- retryOnMount: true,
- refetchOnMount: true,
- },
- });
+ }, [whom, thread, mutateAsync]);
return {
- data: data || {},
- ...query,
+ ...rest,
+ markDmRead,
};
}
-export function useDmUnread(whom: string) {
- const unreads = useDmUnreads();
- return unreads.data[whom];
-}
-
export function useArchiveDm() {
const mutationFn = async ({ whom }: { whom: string }) => {
await api.poke({
@@ -808,6 +736,7 @@ export function useUnarchiveDm() {
}
export function useDmRsvpMutation() {
+ const { mutateAsync: markRead } = useMarkReadMutation();
const mutationFn = async ({
ship,
accept,
@@ -815,7 +744,16 @@ export function useDmRsvpMutation() {
ship: string;
accept: boolean;
}) => {
- await api.poke({
+ markRead({
+ source: { dm: { ship } },
+ action: {
+ event: {
+ 'dm-invite': { ship },
+ },
+ },
+ });
+
+ return api.poke({
app: 'chat',
mark: 'chat-dm-rsvp',
json: {
@@ -838,7 +776,7 @@ export function useDmRsvpMutation() {
}
},
onSettled: (_data, _error, variables) => {
- queryClient.invalidateQueries(ChatKeys.unreads());
+ queryClient.invalidateQueries(unreadsKey);
queryClient.invalidateQueries(ChatKeys.pending());
queryClient.invalidateQueries(['dms', 'dms']);
queryClient.invalidateQueries(['dms', variables.ship]);
@@ -968,6 +906,7 @@ export function useRemoveFromMultiDm() {
}
export function useMutliDmRsvpMutation() {
+ const { mutateAsync: markRead } = useMarkReadMutation();
const mutationFn = async ({
id,
accept,
@@ -978,7 +917,16 @@ export function useMutliDmRsvpMutation() {
const action = multiDmAction(id, {
team: { ship: window.our, ok: accept },
});
- await api.poke(action);
+
+ markRead({
+ source: { dm: { club: id } },
+ action: {
+ event: {
+ 'dm-invite': { club: id },
+ },
+ },
+ });
+ return api.poke(action);
};
return useMutation({
@@ -1278,7 +1226,7 @@ export const infiniteDMsQueryFn =
};
export function useInfiniteDMs(whom: string, initialTime?: string) {
- const unread = useDmUnread(whom);
+ const unread = useUnread(getKey(whom));
const isDM = useMemo(() => whomIsDm(whom), [whom]);
const type = useMemo(() => (isDM ? 'dm' : 'club'), [isDM]);
const queryKey = useMemo(() => ['dms', whom, 'infinite'], [whom]);
@@ -1671,12 +1619,6 @@ export function useDeleteDMReplyReactMutation() {
});
}
-export function useIsDmUnread(whom: string) {
- const chatInfo = useChatInfo(whom);
- const unread = chatInfo?.unread;
- return Boolean(unread && !unread.seen);
-}
-
const selChats = (s: ChatStore) => s.chats;
export function useCheckDmUnread() {
const chats = useChatStore(selChats);
@@ -1711,7 +1653,7 @@ export function useChatStoreDmUnreads(): string[] {
}
export function useMultiDmIsPending(id: string): boolean {
- const unread = useDmUnread(id);
+ const unread = useUnread(getKey(id));
const chat = useMultiDm(id);
const isPending = chat && chat.hive.includes(window.our);
diff --git a/apps/tlon-web/src/state/settings.ts b/apps/tlon-web/src/state/settings.ts
index 2d40554f8a..50f2072968 100644
--- a/apps/tlon-web/src/state/settings.ts
+++ b/apps/tlon-web/src/state/settings.ts
@@ -78,6 +78,7 @@ export interface SettingsState {
disableRemoteContent: boolean;
disableSpellcheck: boolean;
disableNicknames: boolean;
+ showUnreadCounts: boolean;
};
tiles: {
order: string[];
@@ -202,6 +203,7 @@ const emptyCalm: SettingsState['calmEngine'] = {
disableRemoteContent: false,
disableSpellcheck: false,
disableNicknames: false,
+ showUnreadCounts: false,
};
const loadingCalm: SettingsState['calmEngine'] = {
@@ -210,6 +212,7 @@ const loadingCalm: SettingsState['calmEngine'] = {
disableRemoteContent: true,
disableSpellcheck: true,
disableNicknames: true,
+ showUnreadCounts: false,
};
export function useCalm() {
diff --git a/apps/tlon-web/src/state/unreads.ts b/apps/tlon-web/src/state/unreads.ts
new file mode 100644
index 0000000000..44977369dd
--- /dev/null
+++ b/apps/tlon-web/src/state/unreads.ts
@@ -0,0 +1,245 @@
+import {
+ Activity,
+ ActivitySummary,
+} from '@tloncorp/shared/dist/urbit/activity';
+import produce from 'immer';
+import { useCallback } from 'react';
+import create from 'zustand';
+
+import { createDevLogger } from '@/logic/utils';
+
+export type ReadStatus = 'read' | 'seen' | 'unread';
+
+/**
+ *
+ */
+export interface Unread {
+ status: ReadStatus;
+ notify: boolean;
+ count: number;
+ combined: {
+ status: ReadStatus;
+ count: number;
+ notify: boolean;
+ };
+ recency: number;
+ lastUnread?: {
+ id: string;
+ time: string;
+ };
+ children: string[];
+ readTimeout: number;
+ summary: ActivitySummary; // lags behind actual unread, only gets update if unread
+}
+
+export interface Unreads {
+ [source: string]: Unread;
+}
+
+export interface UnreadsStore {
+ loaded: boolean;
+ sources: Unreads;
+ seen: (whom: string) => void;
+ read: (whom: string) => void;
+ delayedRead: (whom: string, callback: () => void) => void;
+ handleUnread: (whom: string, unread: ActivitySummary) => void;
+ update: (unreads: Activity) => void;
+}
+
+export const unreadStoreLogger = createDevLogger('UnreadsStore', true);
+
+export function isUnread(count: number, notify: boolean): boolean {
+ return Boolean(count > 0 || notify);
+}
+
+function sumChildren(source: string, unreads: Unreads): number {
+ const children = unreads[source]?.children || [];
+ return children.reduce((acc, child) => acc + (unreads[child]?.count || 0), 0);
+}
+
+function getUnread(
+ source: string,
+ summary: ActivitySummary,
+ unreads: Unreads
+): Unread {
+ const topNotify = summary.unread?.notify || false;
+ const topCount = summary.unread?.count || 0;
+ const combinedCount = source.startsWith('group/')
+ ? sumChildren(source, unreads)
+ : summary.count || 0;
+ const combinedNotify = summary.notify;
+ return {
+ children: summary.children,
+ recency: summary.recency,
+ readTimeout: 0,
+ notify: topNotify,
+ count: topCount,
+ status: isUnread(topCount, topNotify) ? 'unread' : 'read',
+ combined: {
+ count: combinedCount,
+ notify: combinedNotify,
+ status: isUnread(combinedCount, combinedNotify) ? 'unread' : 'read',
+ },
+ lastUnread: !summary.unread
+ ? undefined
+ : {
+ id: summary.unread.id,
+ time: summary.unread.time,
+ },
+ // todo account for children "seen"
+ summary,
+ };
+}
+
+export const emptyUnread = (): Unread => ({
+ summary: {
+ recency: 0,
+ count: 0,
+ notify: false,
+ unread: null,
+ children: [],
+ },
+ recency: 0,
+ status: 'read',
+ notify: false,
+ count: 0,
+ combined: {
+ status: 'read',
+ count: 0,
+ notify: false,
+ },
+ readTimeout: 0,
+ children: [],
+});
+export const useUnreadsStore = create((set, get) => ({
+ sources: {},
+ loaded: false,
+ update: (summaries) => {
+ set(
+ produce((draft: UnreadsStore) => {
+ draft.loaded = true;
+ Object.entries(summaries).forEach(([key, summary]) => {
+ const source = draft.sources[key];
+ unreadStoreLogger.log('update', key, source, summary, draft.sources);
+ draft.sources[key] = getUnread(key, summary, draft.sources);
+ });
+ })
+ );
+ },
+ handleUnread: (key, summary) => {
+ set(
+ produce((draft: UnreadsStore) => {
+ const source = draft.sources[key] || emptyUnread();
+ unreadStoreLogger.log('unread', key, source, summary);
+ draft.sources[key] = getUnread(key, summary, draft.sources);
+ })
+ );
+ },
+ seen: (key) => {
+ set(
+ produce((draft: UnreadsStore) => {
+ if (!draft.sources[key]) {
+ draft.sources[key] = emptyUnread();
+ }
+
+ const source = draft.sources[key];
+
+ unreadStoreLogger.log('seen', key);
+ draft.sources[key] = {
+ ...source,
+ status: 'seen',
+ };
+ })
+ );
+ },
+ read: (key) => {
+ set(
+ produce((draft: UnreadsStore) => {
+ const source = draft.sources[key];
+ if (!source) {
+ return;
+ }
+
+ if (source.readTimeout) {
+ unreadStoreLogger.log('clear delayedRead', key);
+ clearTimeout(source.readTimeout);
+ }
+
+ unreadStoreLogger.log('read', key, JSON.stringify(source));
+ draft.sources[key] = {
+ ...source,
+ status: 'read',
+ readTimeout: 0,
+ };
+ unreadStoreLogger.log('post read', JSON.stringify(draft.sources[key]));
+ })
+ );
+ },
+ delayedRead: (key, cb) => {
+ const { sources, read } = get();
+ const source = sources[key] || emptyUnread();
+
+ if (source.readTimeout) {
+ clearTimeout(source.readTimeout);
+ }
+
+ const readTimeout = setTimeout(() => {
+ read(key);
+ cb();
+ }, 15 * 1000); // 15 seconds
+
+ set(
+ produce((draft) => {
+ const latest = draft.sources[key] || emptyUnread();
+ unreadStoreLogger.log('delayedRead', key, source, { ...latest });
+ draft.sources[key] = {
+ ...latest,
+ readTimeout,
+ };
+ })
+ );
+ },
+}));
+
+const defaultUnread = {
+ unread: false,
+ count: 0,
+ notify: false,
+};
+export function useCombinedChatUnreads() {
+ const sources = useUnreadsStore(useCallback((s) => s.sources, []));
+ return Object.entries(sources).reduce((acc, [key, source]) => {
+ if (key === 'base' || key.startsWith('group')) {
+ return acc;
+ }
+
+ return {
+ unread: acc.unread || source.status === 'unread',
+ count: acc.count + source.count,
+ notify: acc.notify || source.notify,
+ };
+ }, defaultUnread);
+}
+
+export function useCombinedGroupUnreads() {
+ const sources = useUnreadsStore(useCallback((s) => s.sources, []));
+ return Object.entries(sources).reduce((acc, [key, source]) => {
+ if (!key.startsWith('group')) {
+ return acc;
+ }
+
+ return {
+ unread: acc.unread || source.combined.status === 'unread',
+ count: acc.count + source.combined.count,
+ notify: acc.notify || source.combined.notify,
+ };
+ }, defaultUnread);
+}
+
+export function useUnreads() {
+ return useUnreadsStore(useCallback((s) => s.sources, []));
+}
+
+export function useUnread(key: string): Unread | undefined {
+ return useUnreadsStore(useCallback((s) => s.sources[key], [key]));
+}
diff --git a/desk/app/activity.hoon b/desk/app/activity.hoon
index 84dc8afc28..968b6cf999 100644
--- a/desk/app/activity.hoon
+++ b/desk/app/activity.hoon
@@ -1,20 +1,20 @@
::
-/- a=activity
-/+ default-agent, verb, dbug
+/- a=activity, c=channels, ch=chat, g=groups
+/+ default-agent, verb, dbug, ch-utils=channel-utils, v=volume
::
=>
|%
+$ card card:agent:gall
::
+$ versioned-state
- $% state-0
+ $% state-1
==
::
- +$ state-0
- [%0 =stream:a =indices:a =volume:a]
+ +$ state-1
+ [%1 =indices:a =activity:a =volume-settings:a]
--
::
-=| state-0
+=| state-1
=* state -
::
%- agent:dbug
@@ -71,36 +71,199 @@
::
++ init
^+ cor
- cor
+ (emit %pass /migrate %agent [our.bowl dap.bowl] %poke noun+!>(%migrate))
+::
+++ migrate
+ =. indices (~(put by indices) [%base ~] [*stream:a *reads:a])
+ =. cor set-chat-reads
+ =+ .^(=channels:c %gx (welp channels-prefix /v2/channels/full/noun))
+ =. cor (set-volumes channels)
+ (set-channel-reads channels)
::
++ load
|= =vase
^+ cor
+ ?: ?=([%0 *] q.vase) init
=+ !<(old=versioned-state vase)
- ?> ?=(%0 -.old)
+ ?> ?=(%1 -.old)
=. state old
cor
::
+++ groups-prefix /(scot %p our.bowl)/groups/(scot %da now.bowl)
+++ channels-prefix /(scot %p our.bowl)/channels/(scot %da now.bowl)
+++ set-channel-reads
+ |= =channels:c
+ ^+ cor
+ =+ .^(=unreads:c %gx (welp channels-prefix /v1/unreads/noun))
+ =/ entries ~(tap by unreads)
+ =; events=(list [time incoming-event:a])
+ |-
+ ?~ events cor
+ =. cor (%*(. add should-notify |, start-time -.i.events) +.i.events)
+ $(events t.events)
+ |- ^- (list [time incoming-event:a])
+ ?~ entries ~
+ =/ head i.entries
+ =* next $(entries t.entries)
+ =/ [=nest:c =unread:c] head
+ =/ channel (~(get by channels) nest)
+ ?~ channel next
+ =/ group group.perm.u.channel
+ =; events=(list [time incoming-event:a])
+ (weld events next)
+ =/ posts=(list [time incoming-event:a])
+ ?~ unread.unread ~
+ %+ murn
+ (tab:on-posts:c posts.u.channel `(sub id.u.unread.unread 1) count.u.unread.unread)
+ |= [=time post=(unit post:c)]
+ ?~ post ~
+ =/ key=message-key:a
+ :_ time
+ [author.u.post time]
+ =/ mention
+ (was-mentioned:ch-utils content.u.post our.bowl)
+ `[time %post key nest group content.u.post mention]
+ =/ replies=(list [time incoming-event:a])
+ %- zing
+ %+ murn
+ ~(tap by threads.unread)
+ |= [=id-post:c [id=id-reply:c count=@ud]]
+ ^- (unit (list [time incoming-event:a]))
+ =/ post=(unit (unit post:c)) (get:on-posts:c posts.u.channel id-post)
+ ?~ post ~
+ ?~ u.post ~
+ %- some
+ %+ turn
+ (tab:on-replies:c replies.u.u.post `(sub id 1) count)
+ |= [=time =reply:c]
+ =/ key=message-key:a
+ :_ time
+ [author.reply time]
+ =/ parent=message-key:a
+ :_ id-post
+ [author.u.u.post id-post]
+ =/ mention
+ (was-mentioned:ch-utils content.reply our.bowl)
+ [time %reply key parent nest group content.reply mention]
+ =/ init-time
+ ?: &(=(posts ~) =(replies ~)) recency.unread
+ *@da
+ :- [init-time %chan-init nest group]
+ (welp posts replies)
+++ chat-prefix /(scot %p our.bowl)/chat/(scot %da now.bowl)
+++ set-chat-reads
+ ^+ cor
+ =+ .^(=unreads:ch %gx (welp chat-prefix /unreads/noun))
+ =+ .^ [dms=(map ship dm:ch) clubs=(map id:club:ch club:ch)]
+ %gx (welp chat-prefix /full/noun)
+ ==
+ =/ entries ~(tap by unreads)
+ =; events=(list [time incoming-event:a])
+ |-
+ ?~ events cor
+ =. cor (%*(. add should-notify |, start-time -.i.events) +.i.events)
+ $(events t.events)
+ |- ^- (list [time incoming-event:a])
+ ?~ entries ~
+ =/ head i.entries
+ =* next $(entries t.entries)
+ =/ [=whom:ch =unread:unreads:ch] head
+ =/ =pact:ch
+ ?- -.whom
+ %ship pact:(~(gut by dms) p.whom *dm:ch)
+ %club pact:(~(gut by clubs) p.whom *club:ch)
+ ==
+ =; events=(list [time incoming-event:a])
+ (weld events next)
+ =/ writs=(list [time incoming-event:a])
+ ?~ unread.unread ~
+ %+ murn
+ (tab:on:writs:ch wit.pact `(sub time.u.unread.unread 1) count.u.unread.unread)
+ |= [=time =writ:ch]
+ =/ key=message-key:a [id.writ time]
+ =/ mention
+ (was-mentioned:ch-utils content.writ our.bowl)
+ `[time %dm-post key whom content.writ mention]
+ =/ replies=(list [time incoming-event:a])
+ %- zing
+ %+ murn
+ ~(tap by threads.unread)
+ |= [parent=message-key:ch [key=message-key:ch count=@ud]]
+ ^- (unit (list [time incoming-event:a]))
+ =/ writ=(unit writ:ch) (get:on:writs:ch wit.pact time.parent)
+ ?~ writ ~
+ %- some
+ %+ turn
+ (tab:on:replies:ch replies.u.writ `(sub time.key 1) count)
+ |= [=time =reply:ch]
+ =/ mention
+ (was-mentioned:ch-utils content.reply our.bowl)
+ [time %dm-reply key parent whom content.reply mention]
+ =/ init-time
+ ?: &(=(writs ~) =(replies ~)) recency.unread
+ *@da
+ :- [init-time %dm-invite whom]
+ (welp writs replies)
+++ set-volumes
+ |= =channels:c
+ :: set all existing channels to old default since new default is different
+ =. cor
+ =/ entries ~(tap by channels)
+ |-
+ ?~ entries cor
+ =/ [=nest:c =channel:c] i.entries
+ =. cor
+ %+ adjust [%channel nest group.perm.channel]
+ `(my [%post & |] ~)
+ $(entries t.entries)
+ =+ .^(=volume:v %gx (welp groups-prefix /volume/all/noun))
+ :: set any overrides from previous volume settings
+ =. cor (adjust [%base ~] `(~(got by old-volumes:a) base.volume))
+ =. cor
+ =/ entries ~(tap by chan.volume)
+ |-
+ ?~ entries cor
+ =/ [=nest:g =level:v] i.entries
+ ?. ?=(?(%chat %diary %heap) -.nest) $(entries t.entries)
+ =/ channel (~(get by channels) nest)
+ ?~ channel $(entries t.entries)
+ =. cor
+ %+ adjust [%channel nest group.perm.u.channel]
+ `(~(got by old-volumes:a) level)
+ $(entries t.entries)
+ =/ entries ~(tap by area.volume)
+ |-
+ ?~ entries cor
+ =* head i.entries
+ =. cor
+ %+ adjust [%group -.head]
+ `(~(got by old-volumes:a) +.head)
+ $(entries t.entries)
++ poke
|= [=mark =vase]
^+ cor
?+ mark ~|(bad-poke+mark !!)
+ %noun
+ ?+ q.vase ~|(bad-poke+mark !!)
+ %migrate
+ =. state *state-1
+ migrate
+ ==
+ ::
%activity-action
=+ !<(=action:a vase)
?- -.action
- %add
- (add +.action)
- %read
- (read +.action)
- %adjust
- (adjust +.action)
+ %add (add +.action)
+ %del (del +.action)
+ %read (read +.action)
+ %adjust (adjust +.action)
==
==
::
++ watch
|= =(pole knot)
^+ cor
- ?+ pole ~|(bat-watch-path+pole !!)
+ ?+ pole ~|(bad-watch-path+pole !!)
~ ?>(from-self cor)
[%notifications ~] ?>(from-self cor)
[%unreads ~] ?>(from-self cor)
@@ -111,178 +274,213 @@
^- (unit (unit cage))
?+ pole [~ ~]
[%x ~]
- ``activity-full+!>([stream indices (~(run by indices) summarize-unreads)])
+ ``activity-full+!>([indices activity volume-settings])
+ ::
+ :: /all: unified feed (equality of opportunity)
+ ::
[%x %all ~]
- ``activity-stream+!>((tap:on-event:a stream))
+ ``activity-stream+!>(stream:base)
+ ::
[%x %all start=@ count=@ ~]
- =- ``activity-stream+!>(-)
- (tab:on-event:a stream `(slav %da start.pole) (slav %ud count.pole))
+ =- ``activity-stream+!>((gas:on-event:a *stream:a -))
+ (tab:on-event:a stream:base `(slav %da start.pole) (slav %ud count.pole))
+ ::
+ :: /each: unified feed (equality of outcome)
+ ::TODO want to be able to filter for specific events kind too, but that will
+ :: suffer from the "search range" "problem", where we want .count to
+ :: mean entries trawled, not entries returned...
+ ::
+ [%x %each start=@ count=@ ~]
+ =; =stream:a
+ ``activity-stream+!>(-)
+ =/ start (slav %da start.pole)
+ =/ count (slav %ud count.pole)
+ %- ~(rep by indices)
+ |= [[=source:a =stream:a =reads:a] out=stream:a]
+ ^+ out
+ (gas:on-event:a out (tab:on-event:a stream `start count))
+ ::
+ :: /indexed: per-index
+ ::
+ [%x %indexed concern=?([%channel nk=kind:c:a ns=@ nt=@ gs=@ gt=@ rest=*] [%dm whom=@ rest=*])]
+ =/ =source:a
+ ?- -.concern.pole
+ %dm
+ :- %dm
+ ?^ ship=(slaw %p whom.concern.pole)
+ [%ship u.ship]
+ [%club (slav %uv whom.concern.pole)]
+ ::
+ %channel
+ =, concern.pole
+ [%channel [nk (slav %p ns) nt] [(slav %p gs) gt]]
+ ==
+ =/ rest=(^pole knot)
+ ?- -.concern.pole
+ %dm rest.concern.pole
+ %channel rest.concern.pole
+ ==
+ ?~ dice=(~(get by indices) source) [~ ~]
+ ?+ rest ~
+ ~
+ ``activity-stream+!>(stream.u.dice)
+ ::
+ [start=@ count=@ ~]
+ =/ start (slav %da start.rest)
+ =/ count (slav %ud count.rest)
+ =/ ls (tab:on-event:a stream.u.dice `start count)
+ ``activity-stream+!>((gas:on-event:a *stream:a ls))
+ ==
+ :: /event: individual events
+ ::
[%u %event id=@ ~]
- ``loob+!>((has:on-event:a stream (slav %da id.pole)))
+ ``loob+!>((has:on-event:a stream:base (slav %da id.pole)))
+ ::
[%x %event id=@ ~]
- ``activity-event+!>([id.pole (got:on-event:a stream (slav %da id.pole))])
- [%x %unreads ~]
- ``activity-unreads+!>((~(run by indices) summarize-unreads))
+ ``activity-event+!>([id.pole (got:on-event:a stream:base (slav %da id.pole))])
+ ::
+ [%x %activity ~]
+ ``activity-summary+!>(activity)
+ ::
+ [%x %volume-settings ~]
+ ``activity-settings+!>(volume-settings)
==
::
+++ base
+ ^- index:a
+ (~(got by indices) [%base ~])
++ add
- |= =event:a
+ =/ should-notify=? &
+ =/ start-time=time now.bowl
+ |= inc=incoming-event:a
^+ cor
=/ =time-id:a
- =/ t now.bowl
+ =/ t start-time
|-
- ?. (has:on-event:a stream t) t
+ ?. (has:on-event:a stream:base t) t
$(t +(t))
+ =/ notify &(should-notify notify:(get-volume inc))
+ =/ =event:a [inc notify |]
=. cor
- (give %fact ~[/] activity-event+!>([time-id event]))
- =? cor (notifiable event)
+ (give %fact ~[/] activity-update+!>([%add time-id event]))
+ =? cor notify
(give %fact ~[/notifications] activity-event+!>([time-id event]))
- =. stream
- (put:on-event:a stream time-id event)
- ?+ -.event cor
- %dm-post
- =/ index [%dm whom.event]
- =? indices !(~(has by indices) index)
- (~(put by indices) index [*stream:a *reads:a])
- =/ indy (~(got by indices) index)
- =/ new
- :* (put:on-event:a stream.indy time-id event)
- floor.reads.indy
- %^ put:on-parent:a event-parents.reads.indy
- time-id
- [| time-id]
- ==
- =. indices
- (~(put by indices) index new)
- cor
+ =. indices
+ =/ =stream:a (put:on-event:a stream:base time-id event)
+ (~(put by indices) [%base ~] [stream reads:base])
+ =/ =source:a (determine-source inc)
+ ?+ -<.event (add-to-index source time-id event)
+ %chan-init
+ =/ group-src [%group group.event]
+ =. cor (add-to-index source time-id event)
+ (add-to-index group-src time-id event(child &))
+ ::
%dm-reply
- =/ index [%dm whom.event]
- =? indices !(~(has by indices) index)
- (~(put by indices) index *[stream:a reads:a])
- =/ indy (~(got by indices) index)
- =/ new
- :- (put:on-event:a stream.indy time-id event)
- reads.indy
- =. indices
- (~(put by indices) index new)
- cor
+ =/ parent-src [%dm whom.event]
+ =. cor (add-to-index source time-id event)
+ (add-to-index parent-src time-id event(child &))
+ ::
%post
- =/ index [%channel channel.event group.event]
- =? indices !(~(has by indices) index)
- (~(put by indices) index *[stream:a reads:a])
- =/ indy (~(got by indices) index)
- =/ new
- :* (put:on-event:a stream.indy time-id event)
- floor.reads.indy
- %^ put:on-parent:a event-parents.reads.indy
- time-id
- [| time-id]
- ==
- =. indices
- (~(put by indices) index new)
- cor
+ =/ parent-src [%group group.event]
+ =. cor (add-to-index source time-id event)
+ (add-to-index parent-src time-id event(child &))
+ ::
%reply
- =/ index [%channel channel.event group.event]
- =? indices !(~(has by indices) index)
- (~(put by indices) index *[stream:a reads:a])
- =/ indy (~(got by indices) index)
- =/ new
- :- (put:on-event:a stream.indy time-id event)
- reads.indy
- =. indices
- (~(put by indices) index new)
- cor
- ==
-++ loudness
- ^- (map flavor:a flavor-level:a)
- %- malt
- ^- (list [flavor:a flavor-level:a])
- :~ [%dm-invite %notify]
- [%dm-post %notify]
- [%dm-post-mention %notify]
- [%dm-reply %notify]
- [%dm-reply-mention %notify]
- [%kick %default]
- [%join %default]
- [%post %default]
- [%post-mention %notify]
- [%reply %notify]
- [%reply-mention %notify]
- [%flag %default]
+ =/ chan-src [%channel channel.event group.event]
+ =/ group-src [%group group.event]
+ =. cor (add-to-index source time-id event)
+ =. cor (add-to-index chan-src time-id event(child &))
+ (add-to-index group-src time-id event(child &))
==
-++ notifiable
- |= =event:a
- ^- ?
- =/ index (determine-index event)
- =/ =index-level:a
- ?~ index %soft
- (~(gut by volume) u.index %soft)
- ?- index-level
- %loud &
- %hush |
- %soft
- .= %notify
- (~(gut by loudness) (determine-flavor event) %default)
- ==
-++ determine-index
- |= =event:a
- ^- (unit index:a)
- ?+ -.event ~
- %post `[%channel channel.event group.event]
- %reply `[%channel channel.event group.event]
- %dm-post `[%dm whom.event]
- %dm-reply `[%dm whom.event]
+::
+++ del
+ |= =source:a
+ ^+ cor
+ =. indices (~(del by indices) source)
+ =. volume-settings (~(del by volume-settings) source)
+ :: TODO: send notification removals?
+ (give %fact ~[/] activity-update+!>([%del source]))
+++ add-to-index
+ |= [=source:a =time-id:a =event:a]
+ ^+ cor
+ =/ =index:a (~(gut by indices) source *index:a)
+ =/ new=_stream.index
+ (put:on-event:a stream.index time-id event)
+ (update-index source index(stream new) |)
+++ update-index
+ |= [=source:a new=index:a new-floor=?]
+ =? new new-floor
+ (update-floor new)
+ =. indices
+ (~(put by indices) source new)
+ (refresh-summary source)
+++ refresh-summary
+ |= =source:a
+ =/ summary (summarize-unreads source (get-index source))
+ =. activity
+ (~(put by activity) source summary)
+ (give-unreads source)
+++ get-volumes
+ |= =source:a
+ ^- volume-map:a
+ =/ target (~(get by volume-settings) source)
+ ?^ target u.target
+ ?- -.source
+ %base *volume-map:a
+ %group (get-volumes %base ~)
+ %dm (get-volumes %base ~)
+ %dm-thread (get-volumes %dm whom.source)
+ %channel (get-volumes %group group.source)
+ %thread (get-volumes %channel channel.source group.source)
==
-++ determine-flavor
- |= =event:a
- ^- flavor:a
+++ get-volume
+ |= event=incoming-event:a
+ ^- volume:a
+ =/ source (determine-source event)
+ =/ loudness=volume-map:a (get-volumes source)
+ (~(gut by loudness) (determine-event-type event) [unreads=& notify=|])
+++ determine-source
+ |= event=incoming-event:a
+ ^- source:a
?- -.event
- %dm-invite %dm-invite
- %kick %kick
- %join %join
- %flag %flag
- %post
- ?: mention.event %post-mention %post
- %reply
- ?: mention.event %reply-mention %reply
- %dm-post
- ?: mention.event %dm-post-mention %dm-post
- %dm-reply
- ?: mention.event %dm-reply-mention %dm-reply
+ %chan-init [%channel channel.event group.event]
+ %post [%channel channel.event group.event]
+ %reply [%thread parent.event channel.event group.event]
+ %dm-invite [%dm whom.event]
+ %dm-post [%dm whom.event]
+ %dm-reply [%dm-thread parent.event whom.event]
+ %group-invite [%group group.event]
+ %group-kick [%group group.event]
+ %group-join [%group group.event]
+ %group-role [%group group.event]
+ %group-ask [%group group.event]
+ %flag-post [%group group.event]
+ %flag-reply [%group group.event]
+ ==
+++ determine-event-type
+ |= event=incoming-event:a
+ ^- event-type:a
+ ?+ -.event -.event
+ %post ?:(mention.event %post-mention %post)
+ %reply ?:(mention.event %reply-mention %reply)
+ %dm-post ?:(mention.event %dm-post-mention %dm-post)
+ %dm-reply ?:(mention.event %dm-reply-mention %dm-reply)
==
::
++ find-floor
- |= [=index:a mode=$%([%all ~] [%reply parent=time-id:a])]
+ |= [orig=stream:a =reads:a]
^- (unit time)
- ?. (~(has by indices) index) ~
:: starting at the last-known first-unread location (floor), walk towards
:: the present, to find the new first-unread location (new floor)
::
- =/ [orig=stream:a =reads:a]
- (~(got by indices) index)
- ?> |(?=(%all -.mode) (has:on-parent:a event-parents.reads parent.mode))
:: slice off the earlier part of the stream, for efficiency
::
- =/ =stream:a
- =; beginning=time
- (lot:on-event:a orig `beginning ~)
- ?- -.mode
- %all floor.reads
- %reply reply-floor:(got:on-parent:a event-parents.reads parent.mode)
- ==
+ =/ =stream:a (lot:on-event:a orig `floor.reads ~)
=| new-floor=(unit time)
|-
?~ stream new-floor
::
=/ [[=time =event:a] rest=stream:a] (pop:on-event:a stream)
- ?: ?& ?=(%reply -.mode)
- ?| !?=(%reply -.event)
- ?&(?=(?(%dm-post %post) -.event) =(message-key.event parent.mode))
- == ==
- :: we're in reply mode, and it's not a reply event, or a reply to
- :: something else, so, skip
- ::
- $(stream rest)
=; is-read=?
:: if we found something that's unread, we need look no further
::
@@ -290,148 +488,167 @@
:: otherwise, continue our walk towards the present
::
$(new-floor `time, stream rest)
- ?+ -.event !!
- ?(%dm-post %post)
- =* id=time-id:a q.id.message-key.event
- =/ par=(unit event-parent:a) (get:on-parent:a event-parents.reads id)
- ?~(par | seen.u.par)
- ::
- %reply
- =* id=time-id:a q.id.message-key.event
- =/ par=(unit event-parent:a) (get:on-parent:a event-parents.reads id)
- ?~(par | (gte time reply-floor.u.par))
+ :: treat all other events as read
+ ?+ -<.event &
+ ?(%dm-post %dm-reply %post %reply)
+ ?=(^ (get:on-read-items:a items.reads time))
==
::
++ update-floor
|= =index:a
- ^+ cor
- =/ new-floor=(unit time) (find-floor index %all ~)
- =? indices ?=(^ new-floor)
- %+ ~(jab by indices) index
- |= [=stream:a =reads:a]
- [stream reads(floor u.new-floor)]
- cor
+ ^- index:a
+ =/ new-floor=(unit time) (find-floor index)
+ ?~ new-floor index
+ index(floor.reads u.new-floor)
::
++ read
- |= [=index:a action=read-action:a]
+ |= [=source:a action=read-action:a]
^+ cor
+ %+ update-reads source
?- -.action
- %thread
- =/ indy (~(get by indices) index)
- ?~ indy cor
- =/ new
- =- u.indy(event-parents.reads -)
- %+ put:on-parent:a event-parents.reads.u.indy
- =; new-reply-floor=(unit time)
- [id.action [& (fall new-reply-floor id.action)]]
- (find-floor index %reply id.action)
- =. indices
- (~(put by indices) index new)
- =. cor (update-floor index)
- (give-unreads index new)
+ %event
+ |= =index:a
+ ?> ?=(%event -.action)
+ =/ events
+ %+ murn
+ (tap:on-event:a stream.index)
+ |= [=time =event:a]
+ ?. =(-.event event.action) ~
+ `[time event]
+ ?~ events index
+ =- index(items.reads -)
+ %+ put:on-read-items:a items.reads.index
+ [-<.events ~]
::
- %post
- =/ indy (~(get by indices) index)
- ?~ indy cor
- =/ old-event-parent (get:on-parent:a event-parents.reads.u.indy id.action)
- ?~ old-event-parent cor
- =/ new
- =- u.indy(event-parents.reads -)
- %+ put:on-parent:a event-parents.reads.u.indy
- [id.action u.old-event-parent(seen &)]
- =. indices
- (~(put by indices) index new)
- =. cor (update-floor index)
- (give-unreads index new)
+ %item
+ |= =index:a
+ =- index(items.reads -)
+ %+ put:on-read-items:a items.reads.index
+ [id.action ~]
::
%all
- =/ indy (~(get by indices) index)
- ?~ indy cor
- =/ new
- =/ latest=(unit [=time event:a])
- ::REVIEW is this taking the item from the correct end? lol
- (ram:on-event:a stream.u.indy)
- ?~ latest u.indy
- u.indy(reads [time.u.latest ~])
- =. indices
- (~(put by indices) index new)
- (give-unreads index new)
+ |= =index:a
+ =/ latest=(unit [=time event:a])
+ ::REVIEW is this taking the item from the correct end? lol
+ (ram:on-event:a stream.index)
+ index(reads [?~(latest now.bowl time.u.latest) ~])
==
::
+++ get-index
+ |= =source:a
+ (~(gut by indices) source *index:a)
+++ update-reads
+ |= [=source:a updater=$-(index:a index:a)]
+ ^+ cor
+ =/ new (updater (get-index source))
+ =. cor (update-index source new &)
+ ?+ -.source cor
+ %channel (refresh-summary [%group group.source])
+ %dm-thread (refresh-summary [%dm whom.source])
+ ::
+ %thread
+ =. cor (refresh-summary [%channel channel.source group.source])
+ (refresh-summary [%group group.source])
+ ==
++ give-unreads
- |= [=index:a =stream:a =reads:a]
+ |= =source:a
^+ cor
- (give %fact ~[/unreads] activity-index-unreads+!>([index (summarize-unreads [stream reads])]))
+ =/ summary (~(got by activity) source)
+ (give %fact ~[/ /unreads] activity-update+!>(`update:a`[%read source summary]))
::
++ adjust
- |= [=index:a =index-level:a]
+ |= [=source:a volume-map=(unit volume-map:a)]
^+ cor
- =. volume
- (~(put by volume) index index-level)
- cor
+ =. cor (give %fact ~[/] activity-update+!>([%adjust source volume-map]))
+ ?~ volume-map
+ cor(volume-settings (~(del by volume-settings) source))
+ =/ target (~(gut by volume-settings) source *volume-map:a)
+ =. volume-settings
+ (~(put by volume-settings) source (~(uni by target) u.volume-map))
+ :: recalculate activity summary with new settings
+ =. activity
+ %+ ~(put by activity) source
+ (summarize-unreads source (~(gut by indices) source *index:a))
+ (give-unreads source)
::
+++ get-children
+ |= =source:a
+ ^- (list source:a)
+ %+ skim
+ ~(tap in ~(key by indices))
+ |= src=source:a
+ ?+ -.source |
+ %base ?!(?=(%base -.src))
+ %group &(?=(%channel -.src) =(flag.source group.src))
+ %channel &(?=(%thread -.src) =(nest.source channel.src))
+ %dm &(?=(%dm-thread -.src) =(whom.source whom.src))
+ ==
++ summarize-unreads
- |= [=stream:a =reads:a]
- ^- unread-summary:a
+ |= [=source:a index:a]
+ ^- activity-summary:a
=. stream (lot:on-event:a stream `floor.reads ~)
- =/ event-parents event-parents.reads
+ =/ read-items items.reads
:: for each item in reads
- :: remove the post from the event stream
- :: remove replies older than reply-floor from the event stream
- :: then call stream-to-unreads
- |-
- ?~ event-parents
- (stream-to-unreads stream)
- =/ [[=time =event-parent:a] rest=event-parents:a] (pop:on-parent:a event-parents)
- %= $
- event-parents
- rest
+ :: omit:
+ :: if we don't have unreads enabled for that event
+ :: any items that are unread for some reason
+ :: then remove the post or reply from the event stream
+ :: and call stream-to-unreads
::
- stream
- =- +.-
- %^ (dip:on-event:a @) stream
- ~
- |= [@ key=@da =event:a]
- ^- [(unit event:a) ? @]
- ?> ?=(?(%post %reply %dm-post) -.event)
- ?: &(seen.event-parent =(time key))
- [~ | ~]
- ?. =(-.event %reply)
- [`event | ~]
- ?: (lth time.message-key.event reply-floor.event-parent)
- [~ | ~]
- [`event | ~]
- ==
+ :: TODO: flip around and iterate over stream once, cleaning reads out
+ :: and segment replies for unread threads tracking
+ |-
+ =; unread-stream=stream:a
+ =/ children (get-children source)
+ (stream-to-unreads unread-stream floor.reads children source)
+ %+ gas:on-event:a *stream:a
+ %+ murn
+ (tap:on-event:a stream)
+ |= [=time =event:a]
+ ?: (has:on-read-items:a items.reads time) ~
+ ?: child.event ~
+ `[time event]
++ stream-to-unreads
- |= =stream:a
- ^- unread-summary:a
- =/ newest=(unit time) ~
- =/ count 0
- =/ threads=(map message-id:a [oldest-unread=time count=@ud]) ~
+ |= [=stream:a floor=time children=(list source:a) =source:a]
+ ^- activity-summary:a
+ =/ cs=activity-summary:a
+ %+ roll
+ children
+ |= [=source:a sum=activity-summary:a]
+ =/ =index:a (~(gut by indices) source *index:a)
+ =/ as=activity-summary:a
+ (~(gut by activity) source (summarize-unreads source index))
+ %= sum
+ count (^add count.sum count.as)
+ notify |(notify.sum notify.as)
+ newest ?:((gth newest.as newest.sum) newest.as newest.sum)
+ ==
+ =/ newest=time ?:((gth newest.cs floor) newest.cs floor)
+ =/ total count.cs
+ =/ main 0
+ =/ notified=? notify.cs
+ =/ main-notified=? |
+ =| last=(unit message-key:a)
:: for each event
:: update count and newest
:: if reply, update thread state
|-
?~ stream
- :+ (fall newest now.bowl) count
- %+ turn ~(val by threads)
- |= [oldest-unread=time count=@ud]
- [oldest-unread count]
- =/ [[@ =event:a] rest=stream:a] (pop:on-event:a stream)
- =. count +(count)
- =. newest
- ?> ?=(?(%dm-post %post %reply) -.event)
- ::REVIEW should we take timestamp of parent post if reply??
- :: (in which case we would need to do (max newest time.mk.e))
- `time.message-key.event
- =? threads ?=(%reply -.event)
- =/ old
- %+ ~(gut by threads) id.target.event
- [oldest-unread=time.message-key.event count=0]
- %+ ~(put by threads) id.target.event
- :: we don't need to update the timestamp, because we always process the
- :: oldest message first
- ::
- [oldest-unread.old +(count.old)]
+ [newest total notified ?~(last ~ `[u.last main main-notified]) children]
+ =/ [[=time =event:a] rest=stream:a] (pop:on-event:a stream)
+ =/ volume (get-volume -.event)
+ =? notified &(notify.volume notified.event) &
+ =. newest time
+ ?. ?& unreads.volume
+ ::TODO support other event types
+ ?=(?(%dm-post %dm-reply %post %reply) -<.event)
+ ==
+ $(stream rest)
+ =. total +(total)
+ =. main +(main)
+ =? main-notified &(notify:volume notified.event) &
+ =. last
+ ?~ last `key.event
+ last
$(stream rest)
--
diff --git a/desk/app/channels.hoon b/desk/app/channels.hoon
index 76d050bbf3..7b4814da28 100644
--- a/desk/app/channels.hoon
+++ b/desk/app/channels.hoon
@@ -8,7 +8,7 @@
:: note: all subscriptions are handled by the subscriber library so
:: we can have resubscribe loop protection.
::
-/- c=channels, g=groups, ha=hark
+/- c=channels, g=groups, ha=hark, activity
/- meta
/+ default-agent, verb, dbug, sparse, neg=negotiate
/+ utils=channel-utils, volume, s=subscriber
@@ -527,6 +527,13 @@
?~ p.sign cor
%- (slog leaf+"Failed to hark" u.p.sign)
cor
+ ::
+ [%activity %submit ~]
+ ?> ?=(%poke-ack -.sign)
+ ?~ p.sign cor
+ %- (slog leaf+"{} failed to submit activity" u.p.sign)
+ cor
+ ::
[%contacts @ ~]
?> ?=(%poke-ack -.sign)
?~ p.sign cor
@@ -605,9 +612,12 @@
[%v0 +.pole]
?+ pole [~ ~]
[%x ?(%v0 %v1) %channels ~] ``channels+!>((uv-channels-1:utils v-channels))
- [%x %v2 %channels ~] ``channels-2+!>((uv-channels-2:utils v-channels))
+ ::
+ [%x %v2 %channels full=?(~ [%full ~])]
+ ``channels-2+!>((uv-channels-2:utils v-channels !=(full ~)))
+ ::
[%x ?(%v0 %v1) %init ~] ``noun+!>([unreads (uv-channels-1:utils v-channels)])
- [%x %v2 %init ~] ``noun+!>([unreads (uv-channels-2:utils v-channels)])
+ [%x %v2 %init ~] ``noun+!>([unreads (uv-channels-2:utils v-channels |)])
[%x ?(%v0 %v1) %hidden-posts ~] ``hidden-posts+!>(hidden-posts)
[%x ?(%v0 %v1) %unreads ~] ``channel-unreads+!>(unreads)
[%x v=?(%v0 %v1) =kind:c ship=@ name=@ rest=*]
@@ -695,6 +705,10 @@
::
++ from-self =(our src):bowl
::
+++ scry-path
+ |= [agent=term =path]
+ ^- ^path
+ (welp /(scot %p our.bowl)/[agent]/(scot %da now.bowl) path)
++ ca-core
|_ [=nest:c channel=v-channel:c gone=_|]
++ ca-core .
@@ -722,7 +736,63 @@
++ ca-sub-wire (weld ca-area /updates)
++ ca-give-unread
(give %fact ~[/unreads /v0/unreads /v1/unreads] channel-unread-update+!>([nest ca-unread]))
-::
+ ::
+ ++ ca-activity
+ =, activity
+ |%
+ ++ on-post
+ |= v-post:c
+ ^+ ca-core
+ ?: =(author our.bowl) ca-core
+ =/ mention=? (was-mentioned:utils content our.bowl)
+ =/ action
+ [%add %post [[author id] id] nest group.perm.perm.channel content mention]
+ (send ~[action])
+ ++ on-reply
+ |= [parent=v-post:c v-reply:c]
+ ^+ ca-core
+ ?: =(author our.bowl) ca-core
+ =/ mention=? (was-mentioned:utils content our.bowl)
+ =/ in-replies
+ %+ lien (tap:on-v-replies:c replies.parent)
+ |= [=time reply=(unit v-reply:c)]
+ ?~ reply |
+ =(author.u.reply our.bowl)
+ =/ =path (scry-path %activity /volume-settings/noun)
+ =+ .^(settings=volume-settings %gx path)
+ =/ parent-key=message-key [[author id]:parent id.parent]
+ =/ =action
+ :* %add %reply
+ [[author id] id]
+ parent-key
+ nest
+ group.perm.perm.channel
+ content
+ mention
+ ==
+ :: only follow thread if we haven't adjusted settings already
+ :: and if we're the author of the post, mentioned, or in the replies
+ =/ thread=source [%thread parent-key nest group.perm.perm.channel]
+ ?. ?& !(~(has by settings) thread)
+ ?| mention
+ in-replies
+ =(author.parent our.bowl)
+ ==
+ ==
+ (send ~[action])
+ =/ vm=volume-map [[%reply & &] ~ ~]
+ (send ~[[%adjust thread `vm] action])
+ ++ send
+ |= actions=(list action)
+ ^+ ca-core
+ ?. .^(? %gu (scry-path %activity /$))
+ ca-core
+ %- emil
+ %+ turn actions
+ |= =action
+ =/ =cage activity-action+!>(action)
+ [%pass /activity/submit %agent [our.bowl %activity] %poke cage]
+ --
::
:: handle creating a channel
::
@@ -734,6 +804,7 @@
=. channel *v-channel:c
=. group.perm.perm.channel group.create
=. last-read.remark.channel now.bowl
+ =. ca-core (send:ca-activity ~[[%add %chan-init nest group.create]])
=/ =cage [%channel-command !>([%create create])]
(emit %pass (weld ca-area /create) %agent [our.bowl server] %poke cage)
::
@@ -749,6 +820,7 @@
=. last-read.remark.channel now.bowl
=. ca-core ca-give-unread
=. ca-core (ca-response %join group)
+ =. ca-core (send:ca-activity ~[[%add %chan-init n group]])
(ca-safe-sub |)
::
:: handle an action from the client
@@ -772,7 +844,7 @@
=? ca-core =(%read -.a-remark)
%- emil
=/ last-read last-read.remark.channel
- =+ .^(=carpet:ha %gx /(scot %p our.bowl)/hark/(scot %da now.bowl)/desk/groups/latest/noun)
+ =+ .^(=carpet:ha %gx (scry-path %hark /desk/groups/latest/noun))
%+ murn
~(tap by cable.carpet)
|= [=rope:ha =thread:ha]
@@ -808,6 +880,7 @@
==
==
=. ca-core ca-give-unread
+ ::TODO %read activity-action?
(ca-response a-remark)
::
:: proxy command to host
@@ -1132,6 +1205,12 @@
?: ?=(%set -.u-post)
=? recency.remark.channel ?=(^ post.u-post)
(max recency.remark.channel id-post)
+ =? ca-core ?& ?=(^ post.u-post)
+ |(?=(~ post) (gth rev.u.post.u-post rev.u.u.post))
+ ==
+ ::REVIEW this might re-submit on edits. is that what we want?
+ :: it looks like %activity inserts even if it's a duplicate.
+ (on-post:ca-activity u.post.u-post)
?~ post
=/ post=(unit post:c) (bind post.u-post uv-post:utils)
=? ca-core ?=(^ post.u-post)
@@ -1203,6 +1282,8 @@
`(uv-reply:utils id-post u.reply.u-reply)
=? ca-core ?=(^ reply.u-reply)
(on-reply:ca-hark id-post post u.reply.u-reply)
+ =? ca-core ?=(^ reply.u-reply)
+ (on-reply:ca-activity post u.reply.u-reply)
=? pending.channel ?=(^ reply.u-reply)
=/ memo +.+.u.reply.u-reply
=/ client-id [author sent]:memo
@@ -2042,13 +2123,12 @@
++ ca-recheck
|= sects=(set sect:g)
=/ =flag:g group.perm.perm.channel
- =/ groups-prefix /(scot %p our.bowl)/groups/(scot %da now.bowl)
- =/ exists-path (weld groups-prefix /exists/(scot %p p.flag)/[q.flag])
+ =/ exists-path
+ (scry-path %groups /exists/(scot %p p.flag)/[q.flag])
=+ .^(exists=? %gx exists-path)
?. exists ca-core
=/ =path
- %+ weld
- groups-prefix
+ %+ scry-path %groups
/groups/(scot %p p.flag)/[q.flag]/v1/group-ui
=+ .^(group=group-ui:g %gx path)
?. (~(has by channels.group) nest) ca-core
@@ -2071,6 +2151,7 @@
++ ca-leave
=. ca-core ca-simple-leave
=. ca-core (ca-response %leave ~)
+ =. ca-core (send:ca-activity [%del %channel nest group.perm.perm.channel] ~)
=. gone &
ca-core
--
diff --git a/desk/app/chat.hoon b/desk/app/chat.hoon
index e459cf29fd..586acdf5c3 100644
--- a/desk/app/chat.hoon
+++ b/desk/app/chat.hoon
@@ -1,4 +1,4 @@
-/- c=chat, d=channels, g=groups, u=ui, e=epic, old=chat-2
+/- c=chat, d=channels, g=groups, u=ui, e=epic, old=chat-2, activity
/- meta
/- ha=hark
/- contacts
@@ -573,6 +573,12 @@
?~ p.sign cor
%- (slog 'Failed to do chat data migration' u.p.sign)
cor
+ ::
+ [%activity %submit ~]
+ ?> ?=(%poke-ack -.sign)
+ ?~ p.sign cor
+ %- (slog 'Failed to send activity' u.p.sign)
+ cor
::
[%said *]
:: old chat used to fetch previews, we don't do those here anymore
@@ -624,6 +630,7 @@
|= =path
^- (unit (unit cage))
?+ path [~ ~]
+ [%x %full ~] ``noun+!>([dms clubs])
[%x %old ~] ``noun+!>(old-chats) :: legacy data, for migration use
::
[%x %clubs ~] ``clubs+!>((~(run by clubs) |=(=club:c crew.club)))
@@ -754,6 +761,37 @@
=/ =cage hark-action-1+!>([%new-yarn new-yarn])
(pass-hark cage)
::
+++ pass-activity
+ =, activity
+ |= $: =whom
+ $= concern
+ $% [%post key=message-key]
+ [%reply key=message-key top=message-key]
+ [%invite ~]
+ ==
+ content=story:d
+ mention=?
+ ==
+ ^+ cor
+ ?: ?& ?=(?(%post %reply) -.concern)
+ .= our.bowl
+ p.id:?-(-.concern %post key.concern, %reply key.concern)
+ ==
+ cor
+ ?. .^(? %gu /(scot %p our.bowl)/activity/(scot %da now.bowl)/$)
+ cor
+ %- emit
+ =; =cage
+ [%pass /activity/submit %agent [our.bowl %activity] %poke cage]
+ :- %activity-action
+ !> ^- action
+ :- %add
+ ?- -.concern
+ %post [%dm-post key.concern whom content mention]
+ %reply [%dm-reply key.concern top.concern whom content mention]
+ %invite [%dm-invite whom]
+ ==
+::
++ make-notice
|= [=ship text=cord]
^- delta:writs:c
@@ -1131,6 +1169,11 @@
=/ link (welp /dm/(scot %uv id) rest)
[& & rope con link but]
::
+ ++ cu-activity !.
+ |* a=*
+ =. cor (pass-activity [%club id] a)
+ cu-core
+ ::
++ cu-pass
|%
++ act
@@ -1268,6 +1311,9 @@
~
=? cor (want-hark %to-us)
(emit (pass-yarn new-yarn))
+ =/ concern [%post [. q]:p.diff.delta]
+ =/ mention (was-mentioned:utils content.memo our.bowl)
+ =. cu-core (cu-activity concern content.memo mention)
(cu-give-writs-diff diff.delta)
::
%reply
@@ -1299,6 +1345,10 @@
~
=? cor (want-hark %to-us)
(emit (pass-yarn new-yarn))
+ =/ top-con [. q]:p.diff.delta
+ =/ concern [%reply [. q]:id.q.diff.delta top-con]
+ =/ mention (was-mentioned:utils content.memo our.bowl)
+ =. cu-core (cu-activity concern content.memo mention)
(cu-give-writs-diff diff.delta)
==
==
@@ -1546,6 +1596,11 @@
++ di-area `path`/dm/(scot %p ship)
++ di-area-writs `path`/dm/(scot %p ship)/writs
::
+ ++ di-activity !.
+ |* a=*
+ =. cor (pass-activity [%ship ship] a)
+ di-core
+ ::
++ di-spin
|= [rest=path con=(list content:ha) but=(unit button:ha)]
^- new-yarn:ha
@@ -1584,6 +1639,8 @@
=. pact.dm (reduce:di-pact now.bowl diff)
=? cor &(=(net.dm %invited) !=(ship our.bowl))
(give-invites ship)
+ =? di-core &(=(net.dm %invited) !=(ship our.bowl))
+ (di-activity [%invite ~] *story:d &)
?- -.q.diff
?(%del %add-react %del-react) (di-give-writs-diff diff)
::
@@ -1607,6 +1664,11 @@
~
=? cor (want-hark %to-us)
(emit (pass-yarn new-yarn))
+ =/ concern
+ ?: =(net.dm %invited) [%invite ~]
+ [%post p.diff now.bowl]
+ =/ mention (was-mentioned:utils content.memo our.bowl)
+ =. di-core (di-activity concern content.memo mention)
(di-give-writs-diff diff)
::
%reply
@@ -1637,6 +1699,10 @@
~
=? cor (want-hark %to-us)
(emit (pass-yarn new-yarn))
+ =/ top-con [id.writ.u.entry time.writ.u.entry]
+ =/ concern [%reply [id.q.diff now.bowl] top-con]
+ =/ mention (was-mentioned:utils content.memo our.bowl)
+ =. di-core (di-activity concern content.memo mention)
(di-give-writs-diff diff)
==
==
@@ -1657,6 +1723,7 @@
=? cor =(our src):bowl
(emit (proxy-rsvp:di-pass ok))
?> |(=(src.bowl ship) =(our src):bowl)
+ =. cor (pass-activity [%ship ship] [%invite ~] *story:d |)
:: TODO hook into archive
?. ok
%- (note:wood %odd leaf/"gone {}" ~)
diff --git a/desk/app/groups-ui.hoon b/desk/app/groups-ui.hoon
index 82ea6dedfc..30e40a9ba8 100644
--- a/desk/app/groups-ui.hoon
+++ b/desk/app/groups-ui.hoon
@@ -1,4 +1,4 @@
-/- u=ui, g=groups, c=chat, d=channels
+/- u=ui, g=groups, c=chat, d=channels, a=activity
/+ default-agent, dbug, verb, vita-client
:: performance, keep warm
/+ mark-warmer
@@ -104,10 +104,12 @@
|= =(pole knot)
^- (unit (unit cage))
?+ pole [~ ~]
+ [%x %pins ~] ``ui-pins+!>(pins)
+ ::
[%x %init ~]
=+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun))
=+ .^([=unreads:d channels=channels-0:d] (scry %gx %channels /v1/init/noun))
- =+ .^(=chat:u (scry %gx %chat /init/noun))
+ =+ .^(chat=chat-0:u (scry %gx %chat /init/noun))
=+ .^(profile=? (scry %gx %profile /bound/loob))
=/ =init-0:u
:* groups-ui
@@ -118,13 +120,13 @@
chat
profile
==
- ``ui-init+!>(init)
+ ``ui-init+!>(init-0)
[%x %v1 %init ~]
=+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun))
=+ .^([=unreads:d =channels:d] (scry %gx %channels /v2/init/noun))
- =+ .^(=chat:u (scry %gx %chat /init/noun))
+ =+ .^(chat=chat-0:u (scry %gx %chat /init/noun))
=+ .^(profile=? (scry %gx %profile /bound/loob))
- =/ =init:u
+ =/ =init-1:u
:* groups-ui
gangs
channels
@@ -133,15 +135,29 @@
chat
profile
==
- ``ui-init-1+!>(init)
+ ``ui-init-1+!>(init-1)
::
[%x %v1 %heads since=?(~ [u=@ ~])]
=+ .^(chan=channel-heads:d (scry %gx %channels %v2 %heads (snoc since.pole %channel-heads)))
=+ .^(chat=chat-heads:c (scry %gx %chat %heads (snoc since.pole %chat-heads)))
``ui-heads+!>(`mixed-heads:u`[chan chat])
::
- [%x %pins ~]
- ``ui-pins+!>(pins)
+ [%x %v2 %init ~]
+ =+ .^([=groups-ui:g =gangs:g] (scry %gx %groups /init/v1/noun))
+ =+ .^([* =channels:d] (scry %gx %channels /v2/init/noun))
+ =+ .^(chat=chat-0:u (scry %gx %chat /init/noun))
+ =+ .^(=activity:a (scry %gx %activity /activity/noun))
+ =+ .^(profile=? (scry %gx %profile /bound/loob))
+ =/ =init:u
+ :* groups-ui
+ gangs
+ channels
+ activity
+ pins
+ [clubs dms invited]:chat
+ profile
+ ==
+ ``ui-init-2+!>(init)
==
::
++ poke
diff --git a/desk/app/groups.hoon b/desk/app/groups.hoon
index 0d93d171f4..07d06fa077 100644
--- a/desk/app/groups.hoon
+++ b/desk/app/groups.hoon
@@ -3,7 +3,8 @@
:: note: all subscriptions are handled by the subscriber library so
:: we can have resubscribe loop protection.
::
-/- g=groups, zero=groups-0, ha=hark, h=heap, d=channels, c=chat, tac=contacts
+/- g=groups, zero=groups-0, ha=hark, h=heap, d=channels, c=chat, tac=contacts,
+ activity
/- meta
/- e=epic
/+ default-agent, verb, dbug
@@ -21,13 +22,7 @@
+$ current-state
$: %3
groups=net-groups:g
- ::
- $= volume
- $: base=level:v
- area=(map flag:g level:v) :: override per group
- chan=(map nest:g level:v) :: override per channel
- ==
- ::
+ =volume:v
xeno=gangs:g
:: graph -> agent
shoal=(map flag:g dude:gall)
@@ -94,6 +89,16 @@
++ emit |=(=card cor(cards [card cards]))
++ emil |=(caz=(list card) cor(cards (welp (flop caz) cards)))
++ give |=(=gift:agent:gall (emit %give gift))
+::
+++ submit-activity
+ |= =incoming-event:activity
+ ^+ cor
+ ?. .^(? %gu /(scot %p our.bowl)/activity/(scot %da now.bowl)/$)
+ cor
+ %- emit
+ =/ =cage [%activity-action !>(`action:activity`[%add incoming-event])]
+ [%pass /activity/submit %agent [our.bowl %activity] %poke cage]
+::
++ check-known
|= =ship
^- ?(%alien %known)
@@ -449,6 +454,9 @@
::
[%x %volume ~]
``volume-value+!>(base.volume)
+ ::
+ [%x %volume %all ~]
+ ``noun+!>(volume)
::
[%x %volume ship=@ name=@ ~]
=/ ship (slav %p ship.pole)
@@ -533,6 +541,7 @@
~ cor
[%epic ~] (take-epic sign)
[%helm *] cor
+ [%activity %submit *] cor
[%groups %role ~] cor
[?(%hark %groups %chat %heap %diary) ~] cor
[%cast ship=@ name=@ ~] (take-cast [(slav %p ship.pole) name.pole] sign)
@@ -811,6 +820,31 @@
out
(~(put in out) who)
::
+ ++ go-activity
+ =, activity
+ |= $= concern
+ $% [%join =ship]
+ [%kick =ship]
+ [%flag-post key=message-key =nest:c group=flag:g]
+ [%flag-reply key=message-key parent=message-key =nest:c group=flag:g]
+ [%role =ship roles=(set sect:g)]
+ [%ask =ship]
+ ==
+ ^+ go-core
+ =. cor
+ %- submit-activity
+ ^- incoming-event
+ =, concern
+ ?- -.concern
+ %ask [%group-ask ^flag ship]
+ %join [%group-join ^flag ship]
+ %kick [%group-kick ^flag ship]
+ %role [%group-role ^flag ship roles]
+ %flag-post [%flag-post key nest group]
+ %flag-reply [%flag-reply key parent nest group]
+ ==
+ go-core
+ ::
++ go-channel-hosts
^- (set ship)
%- ~(gas in *(set ship))
@@ -1277,6 +1311,8 @@
==
==
=. cor (emit (pass-hark new-yarn))
+ ::TODO want to (go-activity %flag), but we would need
+ :: a more detailed "key" than just the post-key
go-core
++ go-zone-update
|= [=zone:g =delta:zone:g]
@@ -1430,7 +1466,13 @@
==
=? cor go-is-our-bloc
(emit (pass-hark new-yarn))
- go-core
+ =+ ships=~(tap in ships)
+ |-
+ ?~ ships go-core
+ =. go-core
+ =< ?>(?=(%shut -.cordon.group) .) ::NOTE tmi
+ (go-activity %ask i.ships)
+ $(ships t.ships)
::
[%del-ships %ask]
?> |(go-is-bloc =(~(tap in q.diff) ~[src.bowl]))
@@ -1544,6 +1586,12 @@
==
=? cor go-is-our-bloc
(emit (pass-hark new-yarn))
+ =. go-core
+ =+ ships=~(tap in ships)
+ |-
+ ?~ ships go-core
+ =. go-core (go-activity %join i.ships)
+ $(ships t.ships)
?- -.cordon
?(%open %afar) go-core
%shut
@@ -1585,6 +1633,12 @@
==
=? cor go-is-our-bloc
(emit (pass-hark new-yarn))
+ =. go-core
+ =+ ships=~(tap in ships)
+ |-
+ ?~ ships go-core
+ =. go-core (go-activity %kick i.ships)
+ $(ships t.ships)
?: (~(has in ships) our.bowl)
go-core(gone &)
go-core
@@ -1626,7 +1680,11 @@
==
=? cor go-is-our-bloc
(emit (pass-hark new-yarn))
- go-core
+ =+ ships=~(tap in ships)
+ |-
+ ?~ ships go-core
+ =. go-core (go-activity %role i.ships sects.diff)
+ $(ships t.ships)
::
%del-sects
?> go-is-bloc
@@ -1812,6 +1870,16 @@
=/ ga=gang:g (~(gut by xeno) f [~ ~ ~])
ga-core(flag f, gang ga)
::
+ ++ ga-activity
+ =, activity
+ |= concern=[%group-invite =ship]
+ ^+ ga-core
+ =. cor
+ %- submit-activity
+ ^- incoming-event
+ [%group-invite ^flag ship.concern]
+ ga-core
+ ::
++ ga-area `wire`/gangs/(scot %p p.flag)/[q.flag]
++ ga-pass
|%
@@ -1917,7 +1985,7 @@
==
=? cor !(~(has by groups) flag)
(emit (pass-hark new-yarn))
- ga-core
+ (ga-activity %group-invite src.bowl)
::
==
::
diff --git a/desk/desk.bill b/desk/desk.bill
index 0b7f1f2fe5..6e12fa326c 100644
--- a/desk/desk.bill
+++ b/desk/desk.bill
@@ -8,4 +8,5 @@
%channels
%channels-server
%profile
+ %activity
==
diff --git a/desk/lib/activity-json.hoon b/desk/lib/activity-json.hoon
new file mode 100644
index 0000000000..4dcc5de36f
--- /dev/null
+++ b/desk/lib/activity-json.hoon
@@ -0,0 +1,519 @@
+/- a=activity, g=groups, c=chat
+/+ gj=groups-json, cj=channel-json
+=* z ..zuse
+|%
+++ enjs
+ =, enjs:format
+ |%
+ ::
+ +| %primitives
+ ++ ship
+ |= s=ship:z
+ s+(scot %p s)
+ ++ club-id
+ |= c=id:club:c
+ s+(scot %uv c)
+ ++ whom
+ |= w=whom:a
+ %+ frond -.w
+ ?- -.w
+ %ship (ship p.w)
+ %club (club-id p.w)
+ ==
+ ::
+ ++ msg-key
+ |= k=message-key:a
+ %- pairs
+ :~ id/s+(msg-id id.k)
+ time+s+(scot %ud time.k)
+ ==
+ ::
+ ++ msg-id
+ |= id=message-id:a
+ (rap 3 (scot %p p.id) '/' (scot %ud q.id) ~)
+ ::
+ ++ time-id
+ |= =@da
+ s+`@t`(rsh 4 (scot %ui da))
+ ::
+ +| %basics
+ ::
+ ++ string-source
+ |= s=source:a
+ ^- cord
+ ?- -.s
+ %base 'base'
+ %group (rap 3 'group/' (flag:enjs:gj flag.s) ~)
+ %channel (rap 3 'channel/' (nest:enjs:gj nest.s) ~)
+ ::
+ %dm
+ ?- -.whom.s
+ %ship (rap 3 'ship/' (scot %p p.whom.s) ~)
+ %club (rap 3 'club/' (scot %uv p.whom.s) ~)
+ ==
+ ::
+ %thread
+ %+ rap 3
+ :~ 'thread/'
+ (nest:enjs:gj channel.s)
+ '/'
+ (scot %ud time.key.s)
+ ==
+ ::
+ %dm-thread
+ %+ rap 3
+ :~ 'dm-thread/'
+ ?- -.whom.s
+ %ship (scot %p p.whom.s)
+ %club (scot %uv p.whom.s)
+ ==
+ '/'
+ (msg-id id.key.s)
+ ==
+ ==
+ ::
+ ++ source
+ |= s=source:a
+ %- pairs
+ ?- -.s
+ %base ~[base/~]
+ %group ~[group/s/(flag:enjs:gj flag.s)]
+ %dm ~[dm+(whom whom.s)]
+ ::
+ %channel
+ :~ :- %channel
+ %- pairs
+ :~ nest/s/(nest:enjs:gj nest.s)
+ group/s/(flag:enjs:gj group.s)
+ ==
+ ==
+ ::
+ %thread
+ :~ :- %thread
+ %- pairs
+ :~ channel/s/(nest:enjs:gj channel.s)
+ group/s/(flag:enjs:gj group.s)
+ key+(msg-key key.s)
+ ==
+ ==
+ ::
+ %dm-thread
+ :~ :- %dm-thread
+ %- pairs
+ :~ whom+(whom whom.s)
+ key+(msg-key key.s)
+ ==
+ ==
+ ==
+ ::
+ ++ volume
+ |= v=volume:a
+ %- pairs
+ :~ unreads/b/unreads.v
+ notify/b/notify.v
+ ==
+ ++ reads
+ |= r=reads:a
+ %- pairs
+ :~ floor+(time floor.r)
+ items+(read-items items.r)
+ ==
+ ::
+ ++ read-items
+ |= ri=read-items:a
+ %- pairs
+ %+ turn (tap:on-read-items:a ri)
+ |= [id=time-id:a *]
+ [(scot %ud id) ~]
+ ::
+ ++ unread-point
+ |= up=unread-point:a
+ %- pairs
+ :~ id/s+(msg-id id.up)
+ time+(time-id time.up)
+ count/(numb count.up)
+ notify/b/notify.up
+ ==
+ ++ activity-summary
+ |= sum=activity-summary:a
+ %- pairs
+ :~ recency+(time newest.sum)
+ count+(numb count.sum)
+ notify+b+notify.sum
+ unread/?~(unread.sum ~ (unread-point u.unread.sum))
+ children+a+(turn children.sum |=(s=source:a s/(string-source s)))
+ ==
+ ::
+ ++ event
+ |= e=event:a
+ %+ frond -<.e
+ ?- -<.e
+ %dm-invite (whom whom.e)
+ ::
+ %chan-init
+ %- pairs
+ :~ channel/s+(nest:enjs:gj channel.e)
+ group/s+(flag:enjs:gj group.e)
+ ==
+ ::
+ ?(%group-kick %group-join %group-ask %group-invite)
+ %- pairs
+ :~ group+s+(flag:enjs:gj group.e)
+ ship+(ship ship.e)
+ ==
+ ::
+ %flag-post
+ %- pairs
+ :~ key+(msg-key key.e)
+ channel/s+(nest:enjs:gj channel.e)
+ group/s+(flag:enjs:gj group.e)
+ ==
+ ::
+ %flag-reply
+ %- pairs
+ :~ parent+(msg-key parent.e)
+ key+(msg-key key.e)
+ channel/s+(nest:enjs:gj channel.e)
+ group/s+(flag:enjs:gj group.e)
+ ==
+ ::
+ %dm-post
+ %- pairs
+ :~ key+(msg-key key.e)
+ whom+(whom whom.e)
+ content+(story:enjs:cj content.e)
+ mention/b+mention.e
+ ==
+ ::
+ %dm-reply
+ %- pairs
+ :~ parent+(msg-key parent.e)
+ key+(msg-key key.e)
+ whom+(whom whom.e)
+ content+(story:enjs:cj content.e)
+ mention/b+mention.e
+ ==
+ ::
+ %post
+ %- pairs
+ :~ key+(msg-key key.e)
+ channel/s+(nest:enjs:gj channel.e)
+ group/s+(flag:enjs:gj group.e)
+ content+(story:enjs:cj content.e)
+ mention/b+mention.e
+ ==
+ ::
+ %reply
+ %- pairs
+ :~ parent+(msg-key parent.e)
+ key+(msg-key key.e)
+ channel/s+(nest:enjs:gj channel.e)
+ group/s+(flag:enjs:gj group.e)
+ content+(story:enjs:cj content.e)
+ mention/b+mention.e
+ ==
+ ::
+ %group-role
+ %- pairs
+ :~ group/s+(flag:enjs:gj group.e)
+ ship+(ship ship.e)
+ roles+a+(turn ~(tap in roles.e) |=(role=sect:g s+role))
+ ==
+ ==
+ ::
+ ++ time-event
+ |= te=time-event:a
+ %- pairs
+ :~ time+(time time.te)
+ event+(event event.te)
+ ==
+ +| %collections
+ ::
+ ++ stream
+ |= s=stream:a
+ %- pairs
+ %+ turn (tap:on-event:a s)
+ |= [=time:z e=event:a]
+ [(scot %ud time) (event e)]
+ ::
+ ++ indices
+ |= ind=indices:a
+ %- pairs
+ %+ turn ~(tap by ind)
+ |= [sc=source:a st=stream:a r=reads:a]
+ :- (string-source sc)
+ %- pairs
+ :~ stream+(stream st)
+ reads+(reads r)
+ ==
+ ::
+ ++ activity
+ |= ac=activity:a
+ %- pairs
+ %+ turn ~(tap by ac)
+ |= [s=source:a sum=activity-summary:a]
+ [(string-source s) (activity-summary sum)]
+ ::
+ ++ full-info
+ |= fi=full-info:a
+ %- pairs
+ :~ indices+(indices indices.fi)
+ activity+(activity activity.fi)
+ settings+(volume-settings volume-settings.fi)
+ ==
+ ++ volume-settings
+ |= vs=volume-settings:a
+ %- pairs
+ %+ turn ~(tap by vs)
+ |= [s=source:a v=volume-map:a]
+ [(string-source s) (volume-map v)]
+ ::
+ ++ volume-map
+ |= vm=volume-map:a
+ %- pairs
+ %+ turn ~(tap by vm)
+ |= [e=event-type:a v=volume:a]
+ [e (volume v)]
+ +| %updates
+ ++ update
+ |= u=update:a
+ %+ frond -.u
+ ?- -.u
+ %add (added +.u)
+ %del (source +.u)
+ %read (read +.u)
+ %adjust (adjusted +.u)
+ ==
+ ::
+ ++ added
+ |= ad=time-event:a
+ %- pairs
+ :~ time+(time time.ad)
+ event+(event event.ad)
+ ==
+ ::
+ ++ read
+ |= [s=source:a as=activity-summary:a]
+ %- pairs
+ :~ source+(source s)
+ activity+(activity-summary as)
+ ==
+ ::
+ ++ adjusted
+ |= [s=source:a v=(unit volume-map:a)]
+ %- pairs
+ :~ source+(source s)
+ volume+?~(v ~ (volume-map u.v))
+ ==
+ --
+::
+++ dejs
+ =, dejs:format
+ |%
+ +| %primitives
+ ++ id (se %ud)
+ ++ club-id (se %uv)
+ ++ ship `$-(json ship:z)`(su ship-rule)
+ ++ ship-rule ;~(pfix sig fed:ag)
+ ++ whom
+ %- of
+ :~ ship/ship
+ club/club-id
+ ==
+ ::
+ ++ msg-id
+ ^- $-(json message-id:a)
+ %- su
+ %+ cook |=([p=@p q=@] `id:c`[p `@da`q])
+ ;~((glue fas) ;~(pfix sig fed:ag) dem:ag)
+ ++ msg-key
+ %- ot
+ :~ id/msg-id
+ time/(se %ud)
+ ==
+ ++ event-type
+ %- perk
+ :~ %post-mention
+ %reply-mention
+ %dm-post-mention
+ %dm-reply-mention
+ %post
+ %reply
+ %dm-invite
+ %dm-post
+ %dm-reply
+ %flag-post
+ %flag-reply
+ %group-ask
+ %group-join
+ %group-kick
+ %group-role
+ %group-invite
+ ==
+ +| %action
+ ++ action
+ ^- $-(json action:a)
+ %- of
+ :~ add/add
+ del/source
+ read/read
+ adjust/adjust
+ ==
+ ::
+ ++ add
+ ^- $-(json incoming-event:a)
+ %- of
+ :~ post/post-event
+ reply/reply-event
+ chan-init/chan-init-event
+ dm-invite/whom
+ dm-post/dm-post-event
+ dm-reply/dm-reply-event
+ flag-post/flag-post-event
+ flag-reply/flag-reply-event
+ group-ask/group-event
+ group-join/group-event
+ group-kick/group-event
+ group-invite/group-event
+ group-role/group-role-event
+ ==
+ ::
+ ++ adjust
+ %- ot
+ :~ source/source
+ volume/(mu volume-map)
+ ==
+ ::
+ ++ read
+ ^- $-(json [source:a read-action:a])
+ %- ot
+ :~ source/source
+ action/read-action
+ ==
+ ::
+ ++ read-action
+ %- of
+ :~ all/ul
+ item/id
+ ==
+ ::
+ +| %basics
+ ++ source
+ ^- $-(json source:a)
+ %- of
+ :~ base/ul
+ group/flag:dejs:gj
+ dm/whom
+ channel/channel-source
+ thread/thread-source
+ dm-thread/dm-thread-source
+ ==
+ ::
+ ++ channel-source
+ %- ot
+ :~ nest/nest:dejs:cj
+ group/flag:dejs:gj
+ ==
+ ++ thread-source
+ %- ot
+ :~ key/msg-key
+ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ ==
+ ::
+ ++ dm-thread-source
+ %- ot
+ :~ key/msg-key
+ whom/whom
+ ==
+ ::
+ ++ volume-map
+ |= jon=^json
+ :: ^- $-(json volume-map:a)
+ =/ jom ((om volume) jon)
+ ~& jom
+ ~&
+ %- malt
+ %+ turn ~(tap by jom)
+ |* [a=cord b=*]
+ => .(+< [a b]=+<)
+ ~& a
+ [(rash a event-type) b]
+ ((op event-type volume) jon)
+ ++ volume
+ %- ot
+ :~ unreads/bo
+ notify/bo
+ ==
+ ::
+ ++ chan-init-event
+ %- ot
+ :~ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ ==
+ ::
+ ++ post-event
+ %- ot
+ :~ key/msg-key
+ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ content/story:dejs:cj
+ mention/bo
+ ==
+ ::
+ ++ reply-event
+ %- ot
+ :~ key/msg-key
+ parent/msg-key
+ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ content/story:dejs:cj
+ mention/bo
+ ==
+ ::
+ ++ dm-post-event
+ %- ot
+ :~ key/msg-key
+ whom/whom
+ content/story:dejs:cj
+ mention/bo
+ ==
+ ::
+ ++ dm-reply-event
+ %- ot
+ :~ key/msg-key
+ parent/msg-key
+ whom/whom
+ content/story:dejs:cj
+ mention/bo
+ ==
+ ::
+ ++ flag-post-event
+ %- ot
+ :~ key/msg-key
+ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ ==
+ ::
+ ++ flag-reply-event
+ %- ot
+ :~ key/msg-key
+ parent/msg-key
+ channel/nest:dejs:cj
+ group/flag:dejs:gj
+ ==
+ ::
+ ++ group-event
+ %- ot
+ :~ group/flag:dejs:gj
+ ship/ship
+ ==
+ ::
+ ++ group-role-event
+ %- ot
+ :~ group/flag:dejs:gj
+ ship/ship
+ roles/(as (se %tas))
+ ==
+ --
+--
diff --git a/desk/lib/channel-utils.hoon b/desk/lib/channel-utils.hoon
index 764ac4c87f..b63c836036 100644
--- a/desk/lib/channel-utils.hoon
+++ b/desk/lib/channel-utils.hoon
@@ -24,18 +24,24 @@
==
::
++ uv-channels-2
- |= =v-channels:c
+ |= [=v-channels:c full=?]
^- channels:c
%- ~(run by v-channels)
|= channel=v-channel:c
^- channel:c
- %* . *channel:c
- posts *posts:c
- perm +.perm.channel
- view +.view.channel
- sort +.sort.channel
- order +.order.channel
- pending pending.channel
+ =/ base
+ %* . *channel:c
+ perm +.perm.channel
+ view +.view.channel
+ sort +.sort.channel
+ order +.order.channel
+ pending pending.channel
+ ==
+ ?. full base
+ %_ base
+ posts (uv-posts posts.channel)
+ net net.channel
+ remark remark.channel
==
::
++ uv-posts
diff --git a/desk/lib/mark-warmer.hoon b/desk/lib/mark-warmer.hoon
index 57c86e2ba2..b0e51e8eca 100644
--- a/desk/lib/mark-warmer.hoon
+++ b/desk/lib/mark-warmer.hoon
@@ -1,5 +1,6 @@
/$ init %ui-init %json
/$ init-1 %ui-init-1 %json
+/$ init-2 %ui-init-2 %json
/$ heads %ui-heads %json
/$ pins %ui-pins %json
/$ groups %groups %json
@@ -15,13 +16,13 @@
/$ unreads %channel-unreads %json
/$ heads %channel-heads %json
/$ posts %channel-posts %json
-/$ simple-posts %channel-simple-posts %json
/$ post %channel-post %json
-/$ simple-post %channel-simple-post %json
/$ replies %channel-replies %json
-/$ simple-replies %channel-simple-replies %json
/$ reply %channel-reply %json
-/$ simple-reply %channel-simple-reply %json
+/$ simple-posts %channel-simple-posts %json
+/$ simple-post %channel-simple-post %json
+/$ simple-replies %channel-simple-replies %json
+/$ simple-reply %channel-simple-reply %json
/$ hidden %hidden-posts %json
/$ ch-unreads %chat-unreads %json
/$ writ %writ %json
@@ -29,4 +30,9 @@
/$ clubs %clubs %json
/$ ch-hidden %hidden-messages %json
/$ crew %chat-club-crew %json
+/$ full-info %activity-full %json
+/$ act-update %activity-update %json
+/$ act-sum %activity-summary %json
+/$ act-vol %activity-settings %json
+/$ act-evt %activity-event %json
~
diff --git a/desk/lib/volume.hoon b/desk/lib/volume.hoon
index f8bfec3cf8..c52f45295a 100644
--- a/desk/lib/volume.hoon
+++ b/desk/lib/volume.hoon
@@ -17,6 +17,12 @@
$% [%group flag:g]
[%channel nest:g]
==
++$ volume
+ $: base=level
+ area=(map flag:g level) :: override per group
+ chan=(map nest:g level) :: override per channel
+ ==
+::
:: +fit-level: full "do we want a notification for this" check
::
++ fit-level
diff --git a/desk/mar/activity/action.hoon b/desk/mar/activity/action.hoon
index 9b63fe85f1..b25ba44eea 100644
--- a/desk/mar/activity/action.hoon
+++ b/desk/mar/activity/action.hoon
@@ -1,4 +1,5 @@
/- a=activity
+/+ aj=activity-json
|_ =action:a
++ grad %noun
++ grow
@@ -8,5 +9,6 @@
++ grab
|%
++ noun action:a
+ ++ json action:dejs:aj
--
--
diff --git a/desk/mar/activity/event.hoon b/desk/mar/activity/event.hoon
index 37c865fa88..01fd898a8c 100644
--- a/desk/mar/activity/event.hoon
+++ b/desk/mar/activity/event.hoon
@@ -1,9 +1,11 @@
/- a=activity
+/+ aj=activity-json
|_ =time-event:a
++ grad %noun
++ grow
|%
++ noun time-event
+ ++ json (time-event:enjs:aj time-event)
--
++ grab
|%
diff --git a/desk/mar/activity/full.hoon b/desk/mar/activity/full.hoon
index 04524bd14f..59fe80353f 100644
--- a/desk/mar/activity/full.hoon
+++ b/desk/mar/activity/full.hoon
@@ -1,9 +1,11 @@
/- a=activity
+/+ aj=activity-json
|_ =full-info:a
++ grad %noun
++ grow
|%
++ noun full-info
+ ++ json (full-info:enjs:aj full-info)
--
++ grab
|%
diff --git a/desk/mar/activity/index-unreads.hoon b/desk/mar/activity/index-unreads.hoon
deleted file mode 100644
index 6e4884a09b..0000000000
--- a/desk/mar/activity/index-unreads.hoon
+++ /dev/null
@@ -1,12 +0,0 @@
-/- a=activity
-|_ [=index:a =unread-summary:a]
-++ grad %noun
-++ grow
- |%
- ++ noun [index unread-summary]
- --
-++ grab
- |%
- ++ noun (pair index:a unread-summary:a)
- --
---
diff --git a/desk/mar/activity/settings.hoon b/desk/mar/activity/settings.hoon
new file mode 100644
index 0000000000..563e866e35
--- /dev/null
+++ b/desk/mar/activity/settings.hoon
@@ -0,0 +1,14 @@
+/- a=activity
+/+ aj=activity-json
+|_ vs=volume-settings:a
+++ grad %noun
+++ grow
+ |%
+ ++ noun vs
+ ++ json (volume-settings:enjs:aj vs)
+ --
+++ grab
+ |%
+ ++ noun volume-settings:a
+ --
+--
diff --git a/desk/mar/activity/stream.hoon b/desk/mar/activity/stream.hoon
index 7f672eba15..dc1caeb0f4 100644
--- a/desk/mar/activity/stream.hoon
+++ b/desk/mar/activity/stream.hoon
@@ -1,12 +1,14 @@
/- a=activity
-|_ events=(list time-event:a)
+/+ aj=activity-json
+|_ =stream:a
++ grad %noun
++ grow
|%
- ++ noun events
+ ++ noun stream
+ ++ json (stream:enjs:aj stream)
--
++ grab
|%
- ++ noun (list event:a)
+ ++ noun stream:a
--
--
diff --git a/desk/mar/activity/summary.hoon b/desk/mar/activity/summary.hoon
new file mode 100644
index 0000000000..037aa41eb9
--- /dev/null
+++ b/desk/mar/activity/summary.hoon
@@ -0,0 +1,14 @@
+/- a=activity
+/+ aj=activity-json
+|_ =activity:a
+++ grad %noun
+++ grow
+ |%
+ ++ noun activity
+ ++ json (activity:enjs:aj activity)
+ --
+++ grab
+ |%
+ ++ noun activity:a
+ --
+--
diff --git a/desk/mar/activity/update.hoon b/desk/mar/activity/update.hoon
new file mode 100644
index 0000000000..b27342568d
--- /dev/null
+++ b/desk/mar/activity/update.hoon
@@ -0,0 +1,14 @@
+/- a=activity
+/+ aj=activity-json
+|_ =update:a
+++ grad %noun
+++ grow
+ |%
+ ++ noun update
+ ++ json (update:enjs:aj update)
+ --
+++ grab
+ |%
+ ++ noun update:a
+ --
+--
diff --git a/desk/mar/ui/init-1.hoon b/desk/mar/ui/init-1.hoon
index f28d7b87b9..0ea62b5e8a 100644
--- a/desk/mar/ui/init-1.hoon
+++ b/desk/mar/ui/init-1.hoon
@@ -1,7 +1,6 @@
/- u=ui
/+ gj=groups-json, cj=chat-json, dj=channel-json
-:: group flag + channel flag
-|_ =init:u
+|_ init=init-1:u
++ grad %noun
++ grow
|%
diff --git a/desk/mar/ui/init-2.hoon b/desk/mar/ui/init-2.hoon
new file mode 100644
index 0000000000..7c083abd35
--- /dev/null
+++ b/desk/mar/ui/init-2.hoon
@@ -0,0 +1,31 @@
+/- u=ui
+/+ gj=groups-json, cj=chat-json, dj=channel-json, aj=activity-json
+|_ =init:u
+++ grad %noun
+++ grow
+ |%
+ ++ noun init
+ ++ json
+ =, enjs:format
+ ^- ^json
+ %- pairs
+ :~ groups/(groups-ui:enjs:gj groups.init)
+ gangs/(gangs:enjs:gj gangs.init)
+ channels/(channels-2:enjs:dj channels.init)
+ activity/(activity:enjs:aj activity.init)
+ pins/a/(turn pins.init whom:enjs:gj)
+ profile/b/profile.init
+ ::
+ :- %chat
+ %- pairs
+ :~ clubs/(clubs:enjs:cj clubs.chat.init)
+ dms/a/(turn ~(tap in dms.chat.init) ship:enjs:gj)
+ invited/a/(turn ~(tap in invited.chat.init) ship:enjs:gj)
+ ==
+ ==
+ --
+++ grab
+ |%
+ ++ noun init:u
+ --
+--
diff --git a/desk/sur/activity.hoon b/desk/sur/activity.hoon
index 5aa3fdeef4..e307a3545e 100644
--- a/desk/sur/activity.hoon
+++ b/desk/sur/activity.hoon
@@ -1,82 +1,227 @@
-/- c=channels, g=groups
+/- c=channels, ch=chat, g=groups
/+ mp=mop-extensions
|%
-++ on-event ((on time event) lte)
-++ ex-event ((mp time event) lte)
-++ on-parent ((on time event-parent) lte)
++| %collections
+:: $stream: the activity stream comprised of events from various agents
+$ stream ((mop time event) lte)
-+$ indices (map index [=stream =reads])
-+$ reads
- $: floor=time :: latest time above which everything is read
- =event-parents ::
- ==
-+$ event-parent [seen=? reply-floor=time]
-+$ event-parents ((mop time-id event-parent) lte)
-+$ index
- $% [%channel channel-concern]
- [%dm dm-concern]
+:: $indices: the stream and its read data split into various indices
++$ indices (map source index)
+:: $volume-settings: the volume settings for each source
++$ volume-settings (map source volume-map)
+:: $activity: the current state of activity for each source
++$ activity (map source activity-summary)
+:: $full-info: the full state of the activity stream
++$ full-info [=indices =activity =volume-settings]
+:: $volume-map: how to badge and notify for each event type
++$ volume-map
+ $~ default-volumes
+ (map event-type volume)
++| %actions
+:: $action: how to interact with our activity stream
+::
+:: actions are only ever performed for and by our selves
+::
+:: $add: add an event to the stream
+:: $del: remove a source and all its activity
+:: $read: mark an event as read
+:: $adjust: adjust the volume of an source
+::
++$ action
+ $% [%add =incoming-event]
+ [%del =source]
+ [%read =source =read-action]
+ [%adjust =source =(unit volume-map)]
==
-+$ flavor
- $? %dm-invite
- %dm-post
- %dm-post-mention
- %dm-reply
- %dm-reply-mention
- %kick
- %join
- %post
- %post-mention
- %reply
- %reply-mention
- %flag
+::
+:: $read-action: mark activity read
+::
+:: $item: mark an individual activity as read, indexed by id
+:: $event: mark an individual activity as read, indexed by the event itself
+:: $all: mark _everything_ as read for this source
+::
++$ read-action
+ $% [%item id=time-id]
+ [%event event=incoming-event]
+ [%all ~]
==
-+$ flavor-level ?(%notify %default)
-+$ volume (map index index-level)
-+$ index-level
- $~ %soft
- $? %loud :: always notify
- %soft :: sometimes notify
- %hush :: never notify
+::
++| %updates
+::
+:: $update: what we hear after an action
+::
+:: $add: an event was added to the stream
+:: $del: a source and its activity were removed
+:: $read: a source's activity state was updated
+:: $adjust: the volume of a source was adjusted
+::
++$ update
+ $% [%add time-event]
+ [%del =source]
+ [%read =source =activity-summary]
+ [%adjust =source volume-map=(unit volume-map)]
==
+::
++| %basics
+:: $event: a single point of activity, from one of our sources
+::
+:: $incoming-event: the event that was sent to us
+:: $notified: if this event has been notified
+:: $child: if this event is from a child source
+::
+$ event
- $% [%dm-invite dm-concern]
- [%dm-post dm-post-concern content=story:c mention=?]
- [%dm-reply dm-reply-concern content=story:c mention=?]
- [%kick group-concern =ship]
- [%join group-concern =ship]
- [%post post-concern content=story:c mention=?]
- [%reply reply-concern content=story:c mention=?]
- [%flag post-concern]
+ $: incoming-event
+ notified=?
+ child=?
==
-+$ time-event [=time =event]
-+$ group-concern group=flag:g
-+$ channel-concern [channel=nest:c group=flag:g]
-+$ dm-concern =whom
-+$ dm-post-concern [=message-key =whom]
-+$ dm-reply-concern [=message-key target=message-key =whom]
-+$ post-concern [=message-key channel=nest:c group=flag:g]
-+$ reply-concern [=message-key target=message-key channel=nest:c group=flag:g]
++$ incoming-event
+ $% [%post post-event]
+ [%reply reply-event]
+ [%dm-invite =whom]
+ [%dm-post dm-post-event]
+ [%dm-reply dm-reply-event]
+ [%group-ask group=flag:g =ship]
+ [%group-kick group=flag:g =ship]
+ [%group-join group=flag:g =ship]
+ [%group-invite group=flag:g =ship]
+ [%chan-init channel=nest:c group=flag:g]
+ [%group-role group=flag:g =ship roles=(set sect:g)]
+ [%flag-post key=message-key channel=nest:c group=flag:g]
+ [%flag-reply key=message-key parent=message-key channel=nest:c group=flag:g]
+ ==
+::
++$ post-event
+ $: key=message-key
+ channel=nest:c
+ group=flag:g
+ content=story:c
+ mention=?
+ ==
+::
++$ reply-event
+ $: key=message-key
+ parent=message-key
+ channel=nest:c
+ group=flag:g
+ content=story:c
+ mention=?
+ ==
+::
++$ dm-post-event
+ $: key=message-key
+ =whom
+ content=story:c
+ mention=?
+ ==
+::
++$ dm-reply-event
+ $: key=message-key
+ parent=message-key
+ =whom
+ content=story:c
+ mention=?
+ ==
+::
+:: $source: where the activity is happening
++$ source
+ $% [%base ~]
+ [%group =flag:g]
+ [%channel =nest:c group=flag:g]
+ [%thread key=message-key channel=nest:c group=flag:g]
+ [%dm =whom]
+ [%dm-thread key=message-key =whom]
+ ==
+::
+:: $index: the stream of activity and read state for a source
++$ index [=stream =reads]
+::
+:: $reads: the read state for a source
+::
+:: $floor: the time of the latest event that was read
+:: $items: the set of events above the floor that have been read
+::
++$ reads
+ $: floor=time
+ items=read-items
+ ==
++$ read-items ((mop time-id ,~) lte)
+:: $activity-summary: the summary of activity for a source
+::
+:: $newest: the time of the latest activity read or unread
+:: $count: the total number of unread events including children
+:: $notify: if there are any notifications here or in children
+:: $unread: if the main stream of source is unread: which starting
+:: message, how many there are, and if any are notifications
+:: $children: the sources nested under this source
+::
++$ activity-summary
+ $: newest=time
+ count=@ud
+ notify=_|
+ unread=(unit unread-point)
+ children=(list source)
+ ==
++$ unread-point [message-key count=@ud notify=_|]
++$ volume [unreads=? notify=?]
++| %primitives
+$ whom
$% [%ship p=ship]
- [%club p=@uvH]
+ [%club p=id:club:ch]
==
+$ time-id time
+$ message-id (pair ship time-id)
+$ message-key [id=message-id =time]
-+$ action
- $% [%add =event]
- [%read =index =read-action]
- [%adjust =index =index-level]
+::
++$ event-type
+ $? %chan-init
+ %post
+ %post-mention
+ %reply
+ %reply-mention
+ %dm-invite
+ %dm-post
+ %dm-post-mention
+ %dm-reply
+ %dm-reply-mention
+ %group-invite
+ %group-kick
+ %group-join
+ %group-ask
+ %group-role
+ %flag-post
+ %flag-reply
==
-+$ unread-summary
- $: newest=time
- count=@ud
- threads=(list [oldest-unread=time count=@ud])
++| %helpers
++$ time-event [=time =event]
+++ on-event ((on time event) lte)
+++ ex-event ((mp time event) lte)
+++ on-read-items ((on time ,~) lte)
++| %constants
+++ default-volumes
+ ^~
+ ^- (map event-type volume)
+ %- my
+ :~ [%post & &]
+ [%reply & |]
+ [%dm-reply & &]
+ [%post-mention & &]
+ [%reply-mention & &]
+ [%dm-invite & &]
+ [%dm-post & &]
+ [%dm-post-mention & &]
+ [%dm-reply-mention & &]
+ [%group-invite & &]
+ [%group-ask & &]
+ [%flag-post & &]
+ [%flag-reply & &]
+ [%group-kick & |]
+ [%group-join & |]
+ [%group-role & |]
==
-+$ read-action
- $% [%thread id=time-id] :: mark a whole thread as read
- [%post id=time-id] :: mark an individual post as read
- [%all ~] :: mark _everything_ as read
+++ old-volumes
+ ^~
+ %- my
+ :~ [%soft (~(put by default-volumes) %post [& |])]
+ [%loud (~(run by default-volumes) |=([u=? *] [u &]))]
+ [%hush (~(run by default-volumes) |=([u=? *] [u |]))]
==
-+$ full-info [=stream =indices unreads=(map index unread-summary)]
---
+--
\ No newline at end of file
diff --git a/desk/sur/ui.hoon b/desk/sur/ui.hoon
index a0f58dc99c..0233cd7331 100644
--- a/desk/sur/ui.hoon
+++ b/desk/sur/ui.hoon
@@ -1,28 +1,43 @@
-/- g=groups, d=channels, c=chat
+/- g=groups, d=channels, c=chat, a=activity
|%
+$ init
$: groups=groups-ui:g
=gangs:g
=channels:d
- =unreads:d
+ =activity:a
pins=(list whom)
=chat
profile=?
==
::
++$ init-1
+ $: groups=groups-ui:g
+ =gangs:g
+ =channels:d
+ =unreads:d
+ pins=(list whom)
+ chat=chat-0
+ profile=?
+ ==
+::
+$ init-0
$: groups=groups-ui:g
=gangs:g
channels=channels-0:d
=unreads:d
pins=(list whom)
- =chat
+ chat=chat-0
profile=?
==
::
+$ mixed-heads [chan=channel-heads:d chat=chat-heads:c]
::
+$ chat
+ $: clubs=(map id:club:c crew:club:c)
+ dms=(set ship)
+ invited=(set ship)
+ ==
++$ chat-0
$: clubs=(map id:club:c crew:club:c)
dms=(set ship)
=unreads:c
diff --git a/packages/shared/src/api/initApi.ts b/packages/shared/src/api/initApi.ts
index d9534e7498..23ba4164d6 100644
--- a/packages/shared/src/api/initApi.ts
+++ b/packages/shared/src/api/initApi.ts
@@ -28,17 +28,18 @@ export const getInitData = async () => {
const channelsInit = toClientChannelsInit(response.channels);
const groups = toClientGroups(response.groups, true);
const unjoinedGroups = toClientGroupsFromGangs(response.gangs);
- const channelUnreads = toClientUnreads(response.unreads, 'channel');
+ // const channelUnreads = toClientUnreads(response.unreads, 'channel');
const dmChannels = toClientDms(response.chat.dms);
const groupDmChannels = toClientGroupDms(response.chat.clubs);
const invitedDms = toClientDms(response.chat.invited, true);
- const talkUnreads = toClientUnreads(response.chat.unreads, 'dm');
+ // const talkUnreads = toClientUnreads(response.chat.unreads, 'dm');
return {
pins,
groups,
unjoinedGroups,
- unreads: [...channelUnreads, ...talkUnreads],
+ // unreads: [...channelUnreads, ...talkUnreads],
+ unreads: [],
channels: [...dmChannels, ...groupDmChannels, ...invitedDms],
channelPerms: channelsInit,
};
diff --git a/packages/shared/src/store/sync.test.ts b/packages/shared/src/store/sync.test.ts
index 0696525044..681845dbb0 100644
--- a/packages/shared/src/store/sync.test.ts
+++ b/packages/shared/src/store/sync.test.ts
@@ -357,19 +357,20 @@ test('syncs init data', async () => {
groupsInitData.chat.dms.length +
Object.keys(groupsInitData.chat.clubs).length
);
- const staleChannels = await db.getStaleChannels();
- expect(staleChannels.slice(0, 10).map((c) => [c.id])).toEqual([
- ['chat/~bolbex-fogdys/watercooler-4926'],
- ['chat/~dabben-larbet/hosting-6173'],
- ['chat/~bolbex-fogdys/tlon-general'],
- ['chat/~bolbex-fogdys/marcom'],
- ['heap/~bolbex-fogdys/design-1761'],
- ['chat/~bitpyx-dildus/interface'],
- ['chat/~bolbex-fogdys/ops'],
- ['heap/~dabben-larbet/fanmail-3976'],
- ['diary/~bolbex-fogdys/bulletins'],
- ['chat/~nocsyx-lassul/bongtable'],
- ]);
+ // TODO: fix when activity integrated
+ // const staleChannels = await db.getStaleChannels();
+ // expect(staleChannels.slice(0, 10).map((c) => [c.id])).toEqual([
+ // ['chat/~bolbex-fogdys/watercooler-4926'],
+ // ['chat/~dabben-larbet/hosting-6173'],
+ // ['chat/~bolbex-fogdys/tlon-general'],
+ // ['chat/~bolbex-fogdys/marcom'],
+ // ['heap/~bolbex-fogdys/design-1761'],
+ // ['chat/~bitpyx-dildus/interface'],
+ // ['chat/~bolbex-fogdys/ops'],
+ // ['heap/~dabben-larbet/fanmail-3976'],
+ // ['diary/~bolbex-fogdys/bulletins'],
+ // ['chat/~nocsyx-lassul/bongtable'],
+ // ]);
});
test('syncs thread posts', async () => {
diff --git a/packages/shared/src/store/sync.ts b/packages/shared/src/store/sync.ts
index e8ed72c49a..f8e4d9709f 100644
--- a/packages/shared/src/store/sync.ts
+++ b/packages/shared/src/store/sync.ts
@@ -90,6 +90,7 @@ export const syncUnreads = async () => {
};
const resetUnreads = async (unreads: db.Unread[]) => {
+ if (!unreads.length) return;
await db.insertUnreads(unreads);
await db.setJoinedGroupChannels({
channelIds: unreads
diff --git a/packages/shared/src/urbit/activity.ts b/packages/shared/src/urbit/activity.ts
new file mode 100644
index 0000000000..acb7722f2f
--- /dev/null
+++ b/packages/shared/src/urbit/activity.ts
@@ -0,0 +1,473 @@
+import _ from 'lodash';
+
+import { Story } from './channel';
+import { whomIsDm, whomIsFlag, whomIsMultiDm } from './utils';
+
+export type Whom = { ship: string } | { club: string };
+
+export type ExtendedEventType =
+ | 'post'
+ | 'post-mention'
+ | 'reply'
+ | 'reply-mention'
+ | 'dm-invite'
+ | 'dm-post'
+ | 'dm-post-mention'
+ | 'dm-reply'
+ | 'dm-reply-mention'
+ | 'group-ask'
+ | 'group-join'
+ | 'group-kick'
+ | 'group-invite'
+ | 'group-role'
+ | 'flag-post'
+ | 'flag-reply';
+
+export type NotificationLevel = 'hush' | 'soft' | 'default' | 'medium' | 'loud';
+
+export enum NotificationNames {
+ loud = 'Notify for all activity',
+ default = 'Posts, mentions and replies',
+ medium = 'Posts, mentions and replies ',
+ soft = 'Only mentions and replies',
+ hush = 'Do not notify for any activity',
+}
+
+export interface VolumeLevel {
+ notify: NotificationLevel;
+ unreads: boolean;
+}
+
+export type Volume = {
+ unreads: boolean;
+ notify: boolean;
+};
+
+export interface MessageKey {
+ id: string;
+ time: string;
+}
+
+export interface DmInviteEvent {
+ 'dm-invite': Whom;
+}
+
+export interface GroupKickEvent {
+ 'group-kick': {
+ ship: string;
+ group: string;
+ };
+}
+
+export interface GroupJoinEvent {
+ 'group-join': {
+ ship: string;
+ group: string;
+ };
+}
+
+export interface FlagEvent {
+ flag: {
+ key: MessageKey;
+ channel: string;
+ group: string;
+ };
+}
+
+export interface DmPostEvent {
+ 'dm-post': {
+ key: MessageKey;
+ whom: Whom;
+ content: Story[];
+ mention: boolean;
+ };
+}
+
+export interface DmReplyEvent {
+ 'dm-reply': {
+ parent: MessageKey;
+ key: MessageKey;
+ whom: Whom;
+ content: Story[];
+ mention: boolean;
+ };
+}
+
+export interface PostEvent {
+ post: {
+ key: MessageKey;
+ group: string;
+ channel: string;
+ content: Story[];
+ mention: boolean;
+ };
+}
+
+export interface ReplyEvent {
+ reply: {
+ parent: MessageKey;
+ key: MessageKey;
+ group: string;
+ channel: string;
+ content: Story[];
+ mention: boolean;
+ };
+}
+
+export type ActivityEvent =
+ | DmInviteEvent
+ | GroupKickEvent
+ | GroupJoinEvent
+ | FlagEvent
+ | DmPostEvent
+ | DmReplyEvent
+ | PostEvent
+ | ReplyEvent;
+
+export interface PostRead {
+ seen: boolean;
+ floor: string;
+}
+
+export interface Reads {
+ floor: string;
+ posts: Record;
+}
+
+export interface IndexData {
+ stream: Stream;
+ reads: Reads;
+}
+
+export type Source =
+ | { dm: Whom }
+ | { base: null }
+ | { group: string }
+ | { channel: { nest: string; group: string } }
+ | { thread: { key: MessageKey; channel: string; group: string } }
+ | { 'dm-thread': { key: MessageKey; whom: Whom } };
+
+export interface MessageKey {
+ id: string;
+ time: string;
+}
+
+export interface UnreadPoint extends MessageKey {
+ count: number;
+ notify: boolean;
+}
+
+export interface UnreadThread extends UnreadPoint {
+ 'parent-time': string;
+}
+
+export interface ActivitySummary {
+ recency: number;
+ count: number;
+ notify: boolean;
+ unread: UnreadPoint | null;
+ children: string[];
+}
+
+export type Activity = Record;
+
+export type Indices = Record;
+
+export type Stream = Record;
+
+export type VolumeMap = Partial>;
+
+export type ReadAction =
+ | { event: ActivityEvent }
+ | { item: string }
+ | { all: null };
+
+export interface ActivityReadAction {
+ source: Source;
+ action: ReadAction;
+}
+
+export interface ActivityVolumeAction {
+ source: Source;
+ volume: VolumeMap | null;
+}
+
+export type ActivityAction =
+ | { add: ActivityEvent }
+ | { del: Source }
+ | { read: ActivityReadAction }
+ | { adjust: ActivityVolumeAction };
+
+export interface ActivityReadUpdate {
+ read: {
+ source: Source;
+ activity: ActivitySummary;
+ };
+}
+
+export interface ActivityVolumeUpdate {
+ adjust: {
+ source: Source;
+ volume: VolumeMap | null;
+ };
+}
+
+export interface ActivityAddUpdate {
+ add: {
+ time: string;
+ event: ActivityEvent;
+ };
+}
+
+export interface ActivityDeleteUpdate {
+ del: Source;
+}
+
+export type ActivityUpdate =
+ | ActivityReadUpdate
+ | ActivityVolumeUpdate
+ | ActivityDeleteUpdate
+ | ActivityAddUpdate;
+
+export interface FullActivity {
+ indices: Indices;
+ activity: Activity;
+}
+
+export type VolumeSettings = Record;
+
+export function sourceToString(source: Source, stripPrefix = false): string {
+ if ('base' in source) {
+ return stripPrefix ? '' : 'base';
+ }
+
+ if ('group' in source) {
+ return stripPrefix ? source.group : `group/${source.group}`;
+ }
+
+ if ('channel' in source) {
+ return stripPrefix ? source.channel.nest : `channel/${source.channel.nest}`;
+ }
+
+ if ('dm' in source) {
+ if ('ship' in source.dm) {
+ return stripPrefix ? source.dm.ship : `ship/${source.dm.ship}`;
+ }
+
+ return stripPrefix ? source.dm.club : `club/${source.dm.club}`;
+ }
+
+ if ('thread' in source) {
+ const key = `${source.thread.channel}/${source.thread.key.time}`;
+ return stripPrefix ? key : `thread/${key}`;
+ }
+
+ if ('dm-thread' in source) {
+ const prefix = sourceToString({ dm: source['dm-thread'].whom }, true);
+ const key = `${prefix}/${source['dm-thread'].key.id}`;
+ return stripPrefix ? key : `dm-thread/${key}`;
+ }
+
+ throw new Error('Invalid activity source');
+}
+
+const onEvents: ExtendedEventType[] = [
+ 'dm-reply',
+ 'post-mention',
+ 'reply-mention',
+ 'dm-invite',
+ 'dm-post',
+ 'dm-post-mention',
+ 'dm-reply',
+ 'dm-reply-mention',
+ 'group-ask',
+ 'group-invite',
+ 'flag-post',
+ 'flag-reply',
+];
+
+const notifyOffEvents: ExtendedEventType[] = [
+ 'reply',
+ 'group-join',
+ 'group-kick',
+ 'group-role',
+];
+
+const allEvents: ExtendedEventType[] = [
+ 'post',
+ 'post-mention',
+ 'reply',
+ 'reply-mention',
+ 'dm-invite',
+ 'dm-post',
+ 'dm-post-mention',
+ 'dm-reply',
+ 'dm-reply-mention',
+ 'group-ask',
+ 'group-join',
+ 'group-kick',
+ 'group-invite',
+ 'group-role',
+ 'flag-post',
+ 'flag-reply',
+];
+
+export function getUnreadsFromVolumeMap(vmap: VolumeMap): boolean {
+ return _.some(vmap, (v) => !!v?.unreads);
+}
+
+export function getLevelFromVolumeMap(vmap: VolumeMap): NotificationLevel {
+ const entries = Object.entries(vmap) as [ExtendedEventType, Volume][];
+ if (_.every(entries, ([, v]) => v.notify)) {
+ return 'loud';
+ }
+
+ if (_.every(entries, ([, v]) => !v.notify)) {
+ return 'hush';
+ }
+
+ let isDefault = true;
+ entries.forEach(([k, v]) => {
+ if (onEvents.concat('post').includes(k) && !v.notify) {
+ isDefault = false;
+ }
+
+ if (notifyOffEvents.includes(k) && v.notify) {
+ isDefault = false;
+ }
+ });
+
+ if (isDefault) {
+ return 'medium';
+ }
+
+ return 'soft';
+}
+
+export function getVolumeMap(
+ level: NotificationLevel,
+ unreads: boolean
+): VolumeMap {
+ const emptyMap: VolumeMap = {};
+ if (level === 'loud') {
+ return allEvents.reduce((acc, e) => {
+ acc[e] = { unreads, notify: true };
+ return acc;
+ }, emptyMap);
+ }
+
+ if (level === 'hush') {
+ return allEvents.reduce((acc, e) => {
+ acc[e] = { unreads, notify: false };
+ return acc;
+ }, {} as VolumeMap);
+ }
+
+ return allEvents.reduce((acc, e) => {
+ if (onEvents.includes(e)) {
+ acc[e] = { unreads, notify: true };
+ }
+
+ if (notifyOffEvents.includes(e)) {
+ acc[e] = { unreads, notify: false };
+ }
+
+ if (e === 'post') {
+ acc[e] = { unreads, notify: level === 'medium' || level === 'default' };
+ }
+
+ return acc;
+ }, emptyMap);
+}
+
+export function getDefaultVolumeOption(
+ source: Source,
+ settings: VolumeSettings,
+ group?: string
+): { label: string; volume: NotificationLevel } {
+ const def = 'Use default setting';
+ if ('base' in source) {
+ return {
+ label: def,
+ volume: 'default',
+ };
+ }
+
+ const base: NotificationLevel = settings.base
+ ? getLevelFromVolumeMap(settings.base)
+ : 'default';
+ if ('group' in source || 'dm' in source) {
+ return {
+ label: def,
+ volume: base,
+ };
+ }
+
+ if ('channel' in source && group) {
+ const groupVolume = settings[`group/${group}`];
+ return {
+ label: groupVolume ? 'Use group default' : def,
+ volume: groupVolume ? getLevelFromVolumeMap(groupVolume) : base,
+ };
+ }
+
+ if ('thread' in source) {
+ const channelVolume = settings[`channel/${source.thread.channel}`];
+ return channelVolume
+ ? {
+ label: 'Use channel default',
+ volume: getLevelFromVolumeMap(channelVolume),
+ }
+ : getDefaultVolumeOption(
+ {
+ channel: {
+ nest: source.thread.channel,
+ group: source.thread.group,
+ },
+ },
+ settings,
+ group
+ );
+ }
+
+ if ('dm-thread' in source) {
+ const index = sourceToString({ dm: source['dm-thread'].whom });
+ const dmVolume = settings[index];
+ return dmVolume
+ ? { label: 'Use DM default', volume: getLevelFromVolumeMap(dmVolume) }
+ : getDefaultVolumeOption({ dm: source['dm-thread'].whom }, settings);
+ }
+
+ return {
+ label: def,
+ volume: 'default',
+ };
+}
+
+export function stripSourcePrefix(source: string) {
+ return source.replace(/^[-\w]*\//, '');
+}
+
+export function stripPrefixes(unreads: Activity) {
+ return _.mapKeys(unreads, (v, k) => stripSourcePrefix);
+}
+
+export function onlyChats(unreads: Activity) {
+ return _.pickBy(
+ unreads,
+ (v, k) => k.startsWith('chat/') || whomIsDm(k) || whomIsMultiDm(k)
+ );
+}
+
+export function getKey(whom: string) {
+ return whomIsFlag(whom)
+ ? `channel/chat/${whom}`
+ : whomIsDm(whom)
+ ? `ship/${whom}`
+ : `club/${whom}`;
+}
+
+export function getThreadKey(whom: string, id: string) {
+ const prefix = whomIsFlag(whom) ? 'thread/chat' : 'dm-thread';
+ return `${prefix}/${whom}/${id}`;
+}
diff --git a/packages/shared/src/urbit/dms.ts b/packages/shared/src/urbit/dms.ts
index d4908582ba..2b6aef899d 100644
--- a/packages/shared/src/urbit/dms.ts
+++ b/packages/shared/src/urbit/dms.ts
@@ -330,7 +330,6 @@ export interface ClubAction {
export interface DMInit {
clubs: Clubs;
dms: string[];
- unreads: DMUnreads;
invited: string[];
}
diff --git a/packages/shared/src/urbit/ui.ts b/packages/shared/src/urbit/ui.ts
index 99541e0798..ecd587647c 100644
--- a/packages/shared/src/urbit/ui.ts
+++ b/packages/shared/src/urbit/ui.ts
@@ -1,4 +1,5 @@
-import { Channels, Unreads } from './channel';
+import { Activity } from './activity';
+import { Channels } from './channel';
import { DMInit } from './dms';
import { Gangs, Groups } from './groups';
@@ -6,7 +7,7 @@ export interface GroupsInit {
groups: Groups;
gangs: Gangs;
channels: Channels;
- unreads: Unreads;
+ activity: Activity;
pins: string[];
chat: DMInit;
}
diff --git a/packages/shared/src/urbit/utils.ts b/packages/shared/src/urbit/utils.ts
index 6d382498b2..3ad2a08681 100644
--- a/packages/shared/src/urbit/utils.ts
+++ b/packages/shared/src/urbit/utils.ts
@@ -1,4 +1,4 @@
-import { formatUd, formatUv, unixToDa } from '@urbit/aura';
+import { formatUd, formatUv, isValidPatp, unixToDa } from '@urbit/aura';
import { useMemo } from 'react';
import { GroupJoinStatus, GroupPrivacy } from '../db/schema';
@@ -330,6 +330,20 @@ export function whomIsMultiDm(whom: string): boolean {
return whom.startsWith(`0v`);
}
+// ship + term, term being a @tas: lower-case letters, numbers, and hyphens
+export function whomIsFlag(whom: string): boolean {
+ return (
+ /^~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) && isValidPatp(whom.split('/')[0])
+ );
+}
+
+export function whomIsNest(whom: string): boolean {
+ return (
+ /^[a-z]+\/~[a-z-]+\/[a-z]+[a-z0-9-]*$/.test(whom) &&
+ isValidPatp(whom.split('/')[1])
+ );
+}
+
export function useIsDmOrMultiDm(whom: string) {
return useMemo(() => whomIsDm(whom) || whomIsMultiDm(whom), [whom]);
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4bef7eab86..a7aeaa3a1e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -972,7 +972,7 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@tiptap/core@2.0.3(@tiptap/pm@2.0.3))(@tiptap/pm@2.0.3(@tiptap/core@2.0.3))
'@urbit/api':
- specifier: ^2.2.0
+ specifier: 2.2.0
version: 2.2.0
any-ascii:
specifier: ^0.3.1