diff --git a/config.json b/config.json index 0ebdf7c3..90ed6dfb 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,7 @@ { "defaultHomeserver": 0, "homeserverList": [ + "chat.gamecult.org", "gamecult.org", "converser.eu", "matrix.org", diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts new file mode 100644 index 00000000..bc4a8c28 --- /dev/null +++ b/src/app/components/message/pose/sentiment.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { classifyPose, messagePose } from './sentiment'; + +describe('classifyPose', () => { + it.each([ + ['annoyed', 'ugh, this again'], + ['surprised', 'wow, no way'], + ['amused', 'hahaha that is funny'], + ['happy', 'thanks, great work'], + ['thinking', 'should we ship it?'], + ['neutral', 'Merged the branch.'], + ['neutral', 'sorry, that failed with an error'], + ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); + it('is neutral for empty or non-string text', () => { + expect(classifyPose('')).toBe('neutral'); + expect(classifyPose(undefined)).toBe('neutral'); + expect(classifyPose(5)).toBe('neutral'); + }); + it('is deterministic', () => { + expect(classifyPose('thanks?')).toBe(classifyPose('thanks?')); + }); +}); + +describe('messagePose', () => { + it('lets an explicit label win over the text', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe( + 'annoyed' + ); + }); + it('keeps an unknown explicit label neutral instead of classifying', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe( + 'neutral' + ); + }); + it('classifies when there is no label, and never outputs a signature label', () => { + expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); + expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); + expect(messagePose('aqua', {})).toBe('neutral'); + }); +}); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts new file mode 100644 index 00000000..bd685a07 --- /dev/null +++ b/src/app/components/message/pose/sentiment.ts @@ -0,0 +1,35 @@ +import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; + +// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json +// (npm run sync:poses); nothing else publishes it to the browser. +// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the +// same text always gives the same pose. Rules are tried in order and the first hit wins, so a +// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art +// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only. +const RULES: [string, RegExp][] = [ + ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], + ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], + [ + 'determined', + /\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i, + ], + ['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i], + [ + 'happy', + /\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i, + ], + ['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i], +]; + +export const classifyPose = (text: unknown): string => { + if (typeof text !== 'string') return NEUTRAL; + const hit = RULES.find(([, re]) => re.test(text)); + return hit ? hit[0] : NEUTRAL; +}; + +// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral). +// Only events with no label are read for sentiment. +export const messagePose = (mind: string, content: Record): string => + content[POSE_KEY] !== undefined + ? resolvePose(mind, content[POSE_KEY]) + : resolvePose(mind, classifyPose(content.body)); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 281d4d78..4b3d436f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -61,7 +61,8 @@ import { import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; -import { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { MINDS, mindOfSender } from '../../../components/message/pose/poses'; +import { messagePose } from '../../../components/message/pose/sentiment'; import { useEntranceDelay } from '../../../components/message/pose/entrance'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -835,7 +836,7 @@ export const Message = as<'div', MessageProps>( const poseJSX = !collapse && mind && ( { - it('is gamecult.org when the config names none', () => { - expect(clientDefaultServer({})).toBe('gamecult.org'); - expect(clientDefaultServer({ homeserverList: [] })).toBe('gamecult.org'); + it('is chat.gamecult.org when the config names none', () => { + expect(clientDefaultServer({})).toBe('chat.gamecult.org'); + expect(clientDefaultServer({ homeserverList: [] })).toBe('chat.gamecult.org'); }); it('is the listed server the config points at', () => { @@ -14,7 +14,7 @@ describe('default homeserver', () => { ).toBe('chat.gamecult.org'); }); - it('is gamecult.org in the repo config.json too', () => { - expect(clientDefaultServer(config as ClientConfig)).toBe('gamecult.org'); + it('is chat.gamecult.org in the repo config.json too', () => { + expect(clientDefaultServer(config as ClientConfig)).toBe('chat.gamecult.org'); }); }); diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index feb38e0a..7f0899be 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -53,7 +53,7 @@ export function useClientConfig(): ClientConfig { } export const clientDefaultServer = (clientConfig: ClientConfig): string => - clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'gamecult.org'; + clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'chat.gamecult.org'; export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => { const { homeserverList, allowCustomHomeservers } = clientConfig;