display reactions (#475)

This commit is contained in:
Bob 2024-06-01 14:57:02 +08:00 committed by GitHub
parent fd50df5d89
commit c9b0db17d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 203 additions and 25 deletions

View File

@ -221,6 +221,39 @@ export class App {
})); }));
})(); })();
// Sync events since latest event in the database or beginning of time
{
const latestProfile = args.database.getLatestEvent(NostrKind.META_DATA);
const latestPublic = args.database.getLatestEvent(NostrKind.TEXT_NOTE);
const latestDeletion = args.database.getLatestEvent(NostrKind.DELETE);
const latestReaction = args.database.getLatestEvent(NostrKind.REACTION);
// NOTE:
// 48 hours ago is because the latestEvent now does not distinguish space(relay).
// After adding space, it can be subscribed to as the most recent timestamp.
// So adding "hours ago" can at least have some content.
forever(sync_profile_events({
database: args.database,
pool: args.pool,
since: latestProfile ? hours_ago(latestProfile.created_at, 48) : undefined,
}));
forever(sync_public_notes({
pool: args.pool,
database: args.database,
since: latestPublic ? hours_ago(latestPublic.created_at, 48) : undefined,
}));
forever(sync_deletion_events({
pool: args.pool,
database: args.database,
since: latestDeletion ? hours_ago(latestDeletion.created_at, 48) : undefined,
}));
forever(sync_reaction_events({
pool: args.pool,
database: args.database,
since: latestReaction ? hours_ago(latestReaction.created_at, 48) : undefined,
}));
}
const app = new App( const app = new App(
args.database, args.database,
args.model, args.model,
@ -243,12 +276,9 @@ export class App {
private initApp = async (installPrompt: InstallPrompt) => { private initApp = async (installPrompt: InstallPrompt) => {
console.log("App.initApp"); console.log("App.initApp");
// Sync events // Sync event limit one
{ {
forever(sync_client_specific_data(this.pool, this.ctx, this.database)); forever(sync_client_specific_data(this.pool, this.ctx, this.database));
forever(sync_profile_events(this.database, this.pool));
forever(sync_public_notes(this.pool, this.database));
forever(sync_deletion_events(this.pool, this.database));
} }
(async () => { (async () => {
@ -371,6 +401,7 @@ export class AppComponent extends Component<AppProps, {
isUserBlocked: app.conversationLists.isUserBlocked, isUserBlocked: app.conversationLists.isUserBlocked,
getEventByID: app.database.getEventByID, getEventByID: app.database.getEventByID,
isAdmin: this.state.isAdmin, isAdmin: this.state.isAdmin,
getReactionsByEventID: app.database.getReactionEvents,
}} }}
userBlocker={app.conversationLists} userBlocker={app.conversationLists}
/> />
@ -398,6 +429,7 @@ export class AppComponent extends Component<AppProps, {
isUserBlocked: app.conversationLists.isUserBlocked, isUserBlocked: app.conversationLists.isUserBlocked,
getEventByID: app.database.getEventByID, getEventByID: app.database.getEventByID,
isAdmin: this.state.isAdmin, isAdmin: this.state.isAdmin,
getReactionsByEventID: app.database.getReactionEvents,
}} }}
messages={Array.from( messages={Array.from(
map( map(
@ -547,12 +579,16 @@ async function sync_dm_events(
} }
async function sync_profile_events( async function sync_profile_events(
database: Database_View, args: {
pool: ConnectionPool, database: Database_View;
pool: ConnectionPool;
since: number | undefined;
},
) { ) {
const { database, pool, since } = args;
const messageStream = await pool.newSub("sync_profile_events", { const messageStream = await pool.newSub("sync_profile_events", {
kinds: [NostrKind.META_DATA], kinds: [NostrKind.META_DATA],
since: hours_ago(12), since,
}); });
if (messageStream instanceof Error) { if (messageStream instanceof Error) {
return messageStream; return messageStream;
@ -567,10 +603,17 @@ async function sync_profile_events(
} }
} }
const sync_public_notes = async (pool: ConnectionPool, database: Database_View) => { const sync_public_notes = async (
args: {
pool: ConnectionPool;
database: Database_View;
since: number | undefined;
},
) => {
const { pool, database, since } = args;
const stream = await pool.newSub("sync_public_notes", { const stream = await pool.newSub("sync_public_notes", {
kinds: [NostrKind.TEXT_NOTE, NostrKind.Long_Form], kinds: [NostrKind.TEXT_NOTE, NostrKind.Long_Form],
since: hours_ago(3), since,
}); });
if (stream instanceof Error) { if (stream instanceof Error) {
return stream; return stream;
@ -613,12 +656,16 @@ const sync_client_specific_data = async (
}; };
const sync_deletion_events = async ( const sync_deletion_events = async (
pool: ConnectionPool, args: {
database: Database_View, pool: ConnectionPool;
database: Database_View;
since: number | undefined;
},
) => { ) => {
const { pool, database, since } = args;
const stream = await pool.newSub("sync_deletion_events", { const stream = await pool.newSub("sync_deletion_events", {
kinds: [NostrKind.DELETE], kinds: [NostrKind.DELETE],
since: hours_ago(48), since,
}); });
if (stream instanceof Error) { if (stream instanceof Error) {
return stream; return stream;
@ -639,6 +686,37 @@ const sync_deletion_events = async (
} }
}; };
export function hours_ago(hours: number) { const sync_reaction_events = async (
return Math.floor(Date.now() / 1000) - hours * 60 * 60; args: {
pool: ConnectionPool;
database: Database_View;
since: number | undefined;
},
) => {
const { pool, database, since } = args;
const stream = await pool.newSub("sync_reaction_events", {
kinds: [NostrKind.REACTION],
since,
});
if (stream instanceof Error) {
return stream;
}
for await (const msg of stream.chan) {
if (msg.res.type === "EOSE") {
continue;
} else if (msg.res.type === "NOTICE") {
console.log(`Notice: ${msg.res.note}`);
continue;
}
const ok = await database.addEvent(msg.res.event, msg.url);
if (ok instanceof Error) {
console.error(msg);
console.error(ok);
}
}
};
export function hours_ago(time: number, hours: number) {
return time - hours * 60 * 60;
} }

View File

@ -169,18 +169,18 @@ const handle_update_event = async (chan: PutChannel<true>, args: {
continue; continue;
} }
const current_relay = pool.getRelay(model.currentRelay);
if (current_relay == undefined) {
console.error(Array.from(pool.getRelays()));
continue;
}
const blowater_relay = pool.getRelay(default_blowater_relay); const blowater_relay = pool.getRelay(default_blowater_relay);
if (blowater_relay == undefined) { if (blowater_relay == undefined) {
console.error(Array.from(pool.getRelays())); console.error(Array.from(pool.getRelays()));
continue; continue;
} }
let current_relay = pool.getRelay(model.currentRelay);
if (current_relay == undefined) {
current_relay = blowater_relay;
rememberCurrentRelay(default_blowater_relay);
}
// All events below are only valid after signning in // All events below are only valid after signning in
if (event.type == "SelectRelay") { if (event.type == "SelectRelay") {
rememberCurrentRelay(event.relay.url); rememberCurrentRelay(event.relay.url);

View File

@ -17,7 +17,7 @@ import {
NewMessageChecker, NewMessageChecker,
PinListGetter, PinListGetter,
} from "./conversation-list.tsx"; } from "./conversation-list.tsx";
import { func_GetEventByID, func_IsAdmin } from "./message-list.tsx"; import { func_GetEventByID, func_GetReactionsByEventID, func_IsAdmin } from "./message-list.tsx";
export type DM_Model = { export type DM_Model = {
currentConversation: PublicKey | undefined; currentConversation: PublicKey | undefined;
@ -37,6 +37,7 @@ type DirectMessageContainerProps = {
isUserBlocked: (pubkey: PublicKey) => boolean; isUserBlocked: (pubkey: PublicKey) => boolean;
getEventByID: func_GetEventByID; getEventByID: func_GetEventByID;
isAdmin: func_IsAdmin | undefined; isAdmin: func_IsAdmin | undefined;
getReactionsByEventID: func_GetReactionsByEventID;
}; };
userBlocker: UserBlocker; userBlocker: UserBlocker;
} & DM_Model; } & DM_Model;

View File

@ -55,6 +55,7 @@ interface Props {
relayRecordGetter: RelayRecordGetter; relayRecordGetter: RelayRecordGetter;
getEventByID: func_GetEventByID; getEventByID: func_GetEventByID;
isAdmin: func_IsAdmin | undefined; isAdmin: func_IsAdmin | undefined;
getReactionsByEventID: func_GetReactionsByEventID;
}; };
} }
@ -282,6 +283,8 @@ export type func_GetEventByID = (
id: string | NoteID, id: string | NoteID,
) => Parsed_Event | undefined; ) => Parsed_Event | undefined;
export type func_GetReactionsByEventID = (id: string) => Set<Parsed_Event>;
function MessageBoxGroup(props: { function MessageBoxGroup(props: {
authorProfile: ProfileData | undefined; authorProfile: ProfileData | undefined;
messages: ChatMessage[]; messages: ChatMessage[];
@ -300,6 +303,7 @@ function MessageBoxGroup(props: {
relayRecordGetter: RelayRecordGetter; relayRecordGetter: RelayRecordGetter;
getEventByID: func_GetEventByID; getEventByID: func_GetEventByID;
isAdmin: func_IsAdmin | undefined; isAdmin: func_IsAdmin | undefined;
getReactionsByEventID: func_GetReactionsByEventID;
}; };
}) { }) {
const first_message = props.messages[0]; const first_message = props.messages[0];
@ -353,6 +357,10 @@ function MessageBoxGroup(props: {
props.getters, props.getters,
)} )}
</pre> </pre>
<Reactions
myPublicKey={props.myPublicKey}
events={props.getters.getReactionsByEventID(first_message.event.id)}
/>
</div> </div>
</div> </div>
</li>, </li>,
@ -374,7 +382,7 @@ function MessageBoxGroup(props: {
})} })}
{Time(msg.created_at)} {Time(msg.created_at)}
<div <div
class={`flex-1`} className={`flex-1`}
style={{ style={{
maxWidth: "calc(100% - 2.75rem)", maxWidth: "calc(100% - 2.75rem)",
}} }}
@ -384,6 +392,10 @@ function MessageBoxGroup(props: {
> >
{ParseMessageContent(msg, props.emit, props.getters)} {ParseMessageContent(msg, props.emit, props.getters)}
</pre> </pre>
<Reactions
myPublicKey={props.myPublicKey}
events={props.getters.getReactionsByEventID(msg.event.id)}
/>
</div> </div>
</li>, </li>,
); );
@ -579,3 +591,55 @@ function ReplyTo(
</div> </div>
); );
} }
function Reactions(
props: {
myPublicKey: PublicKey;
events: Set<Parsed_Event>;
},
) {
const reactions: Map<string, {
count: number;
clicked: boolean;
}> = new Map();
for (const event of props.events) {
let reaction = event.content;
if (!reaction) continue;
if (reaction === "+") reaction = "👍";
if (reaction === "-") reaction = "👎";
const pre = reactions.get(reaction);
const isClicked = event.pubkey === props.myPublicKey.hex;
if (pre) {
reactions.set(reaction, {
count: pre.count + 1,
clicked: pre.clicked || isClicked,
});
} else {
reactions.set(reaction, {
count: 1,
clicked: isClicked,
});
}
}
return (
reactions.size > 0
? (
<div class={`flex flex-row justify-start items-center gap-1 py-1`}>
{Array.from(reactions).map(([reaction, { count, clicked }]) => {
return (
<div
class={`flex justify-center items-center rounded-full p-1 text-xs text-neutral-400 leading-4 cursor-default ${
clicked ? "bg-neutral-500" : "bg-neutral-700"
}`}
>
<div class={`flex justify-center items-center w-4`}>{reaction}</div>
{count > 1 && <div>{` ${count}`}</div>}
</div>
);
})}
</div>
)
: null
);
}

View File

@ -26,6 +26,7 @@ import { BlockUser, UnblockUser } from "./user-detail.tsx";
import { import {
DeleteEvent, DeleteEvent,
func_GetEventByID, func_GetEventByID,
func_GetReactionsByEventID,
func_IsAdmin, func_IsAdmin,
MessageList, MessageList,
ReplyToMessage, ReplyToMessage,
@ -75,6 +76,7 @@ interface MessagePanelProps {
isUserBlocked: (pubkey: PublicKey) => boolean; isUserBlocked: (pubkey: PublicKey) => boolean;
getEventByID: func_GetEventByID; getEventByID: func_GetEventByID;
isAdmin: func_IsAdmin | undefined; isAdmin: func_IsAdmin | undefined;
getReactionsByEventID: func_GetReactionsByEventID;
}; };
} }

View File

@ -13,7 +13,7 @@ import { NostrAccountContext } from "../../libs/nostr.ts/nostr.ts";
import { MessagePanel } from "./message-panel.tsx"; import { MessagePanel } from "./message-panel.tsx";
import { PublicKey } from "../../libs/nostr.ts/key.ts"; import { PublicKey } from "../../libs/nostr.ts/key.ts";
import { ChatMessage } from "./message.ts"; import { ChatMessage } from "./message.ts";
import { func_GetEventByID, func_IsAdmin } from "./message-list.tsx"; import { func_GetEventByID, func_GetReactionsByEventID, func_IsAdmin } from "./message-list.tsx";
import { Filter, FilterContent } from "./filter.tsx"; import { Filter, FilterContent } from "./filter.tsx";
import { NoteID } from "../../libs/nostr.ts/nip19.ts"; import { NoteID } from "../../libs/nostr.ts/nip19.ts";
@ -38,6 +38,7 @@ type Props = {
isUserBlocked: func_IsUserBlocked; isUserBlocked: func_IsUserBlocked;
getEventByID: func_GetEventByID; getEventByID: func_GetEventByID;
isAdmin: func_IsAdmin | undefined; isAdmin: func_IsAdmin | undefined;
getReactionsByEventID: func_GetReactionsByEventID;
}; };
} & Public_Model; } & Public_Model;

View File

@ -66,6 +66,11 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
private readonly caster = csp.multi<{ event: Parsed_Event; relay?: string }>(this.sourceOfChange); private readonly caster = csp.multi<{ event: Parsed_Event; relay?: string }>(this.sourceOfChange);
private readonly profiles = new Map<string, Profile_Nostr_Event>(); private readonly profiles = new Map<string, Profile_Nostr_Event>();
private readonly deletionEvents = new Map</* event id */ string, /* deletion event */ Parsed_Event>(); private readonly deletionEvents = new Map</* event id */ string, /* deletion event */ Parsed_Event>();
private readonly reactionEvents = new Map<
/* event id */ string,
/* reaction events */ Set<Parsed_Event>
>();
private readonly latestEvents = new Map<NostrKind, Parsed_Event>();
private constructor( private constructor(
private readonly eventsAdapter: EventsAdapter, private readonly eventsAdapter: EventsAdapter,
@ -126,6 +131,7 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
new Set(all_removed_events.map((mark) => mark.event_id)), new Set(all_removed_events.map((mark) => mark.event_id)),
); );
console.log("Datebase_View:New time spent", Date.now() - t); console.log("Datebase_View:New time spent", Date.now() - t);
for (const event of db.events.values()) { for (const event of db.events.values()) {
if (event.kind == NostrKind.META_DATA) { if (event.kind == NostrKind.META_DATA) {
// @ts-ignore // @ts-ignore
@ -139,9 +145,21 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
event.parsedTags.e.forEach((event_id) => { event.parsedTags.e.forEach((event_id) => {
db.deletionEvents.set(event_id, event); db.deletionEvents.set(event_id, event);
}); });
} else if (event.kind == NostrKind.REACTION) {
const eventId = event.parsedTags.e[0];
const events = db.reactionEvents.get(event.parsedTags.e[0]) || new Set<Parsed_Event>();
events.add(event);
db.reactionEvents.set(eventId, events);
}
// update latest event
const preLatest = db.latestEvents.get(event.kind);
if (preLatest === undefined || preLatest.created_at < event.created_at) {
db.latestEvents.set(event.kind, event);
} }
} }
console.log(`Datebase_View:Deletion events size: ${db.deletionEvents.size}`); console.log(`Datebase_View:Deletion events size: ${db.deletionEvents.size}`);
console.log(`Datebase_View:Reaction events size: ${db.reactionEvents.size}`);
return db; return db;
} }
@ -164,6 +182,10 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
} }
} }
getLatestEvent = (kind: NostrKind) => {
return this.latestEvents.get(kind);
};
isDeleted(id: string, admin?: string) { isDeleted(id: string, admin?: string) {
const deletionEvent = this.deletionEvents.get(id); const deletionEvent = this.deletionEvents.get(id);
if (deletionEvent == undefined) { if (deletionEvent == undefined) {
@ -177,6 +199,10 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
deletionEvent.pubkey == admin; deletionEvent.pubkey == admin;
} }
getReactionEvents = (id: string) => {
return this.reactionEvents.get(id) || new Set<Parsed_Event>();
};
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
this.removedEvents.add(id); this.removedEvents.add(id);
await this.eventMarker.markEvent(id, "removed"); await this.eventMarker.markEvent(id, "removed");
@ -296,6 +322,11 @@ export class Database_View implements ProfileSetter, ProfileGetter, EventRemover
parsedEvent.parsedTags.e.forEach((event_id) => { parsedEvent.parsedTags.e.forEach((event_id) => {
this.deletionEvents.set(event_id, parsedEvent); this.deletionEvents.set(event_id, parsedEvent);
}); });
} else if (parsedEvent.kind == NostrKind.REACTION) {
const eventId = parsedEvent.parsedTags.e[0];
const events = this.reactionEvents.get(eventId) || new Set<Parsed_Event>();
events.add(parsedEvent);
this.reactionEvents.set(eventId, events);
} }
await this.eventsAdapter.put(event); await this.eventsAdapter.put(event);

View File

@ -27,7 +27,8 @@
"vendor", "vendor",
"*cov_profile*", "*cov_profile*",
"*tauri*", "*tauri*",
"app/UI/assets/" "app/UI/assets/",
"*/tailwind.js"
], ],
"indentWidth": 4, "indentWidth": 4,
"lineWidth": 110 "lineWidth": 110

@ -1 +1 @@
Subproject commit 98b715e219dfe2a956736e239e69dc346dec1301 Subproject commit bc371c443d9846be0d3caec17a1e24d93cd4e6a0