atagen

malice

public
Star0
Watch0

Commit 544a170ead2c

tarball

atagen <boss@atagen.co>2026-10-22 13:19 UTC

31 files changed+1255-134
web: move reactions into a hover toolbar with configurable quick reacts and an emoji picker

The fixed QUICK_KEYS row under every message cluttered the timeline and could not reach custom emotes. Quick reacts now live in dev.malice.quick_reactions global account data so they follow the user across devices, which needs the new /api/account-data route and worker ops. mxc:// reaction keys up to 255 chars are allowed for MSC4027 emote reactions. Emoji render through self-hosted twemoji so the offline device build has no CDN dependency, and the server and worker finish the into_login/login_error migration that 5562baa left half done.
Added file: MACLTOK_IMAGE_PACKS.md+58 additions
@@ -0,0 +1,58 @@
1+# macltok request, custom emote and sticker packs (MSC2545)
2+
3+## Problem
4+
5+Malice is adding an emoji picker for reactions that should also offer the user's custom emotes and stickers. Those live in MSC2545 image packs, spread over three places.
6+
7+1. `im.ponies.user_emotes` global account data, the user's personal pack
8+2. `im.ponies.room_emotes` room state, one pack per state key, in the current room
9+3. `im.ponies.emote_rooms` global account data, which names other rooms (and state keys) whose packs the user enabled everywhere
10+
11+Merging those, resolving pack vs image `usage`, and following `emote_rooms` into rooms the client may not have state for is protocol work every consumer would repeat, so it belongs in macltok. Malice can't do it today anyway. `global_account_data` covers 1 and 3, but there is no room state getter for 2.
12+
13+ruma-events 0.34 already has the types behind `unstable-msc2545` (`image_pack.rs`, `RoomImagePackEventContent`, `AccountImagePackEventContent`, `ImagePackRoomsEventContent`, `PackUsage`), including the stable `m.image_pack` aliases.
14+
15+## Request
16+
17+A normalised query for the packs usable in a given room.
18+
19+```rust
20+impl ClientHandle {
21+ /// user pack, then the room's own packs, then emote_rooms packs, deduplicated
22+ pub async fn image_packs(&self, room_id: Option<OwnedRoomId>) -> Result<Vec<ImagePack>, Error>;
23+}
24+
25+pub struct ImagePack {
26+ pub id: String,
27+ pub display_name: String,
28+ pub avatar_url: Option<OwnedMxcUri>,
29+ pub source: ImagePackSource,
30+ pub images: Vec<PackImage>,
31+}
32+
33+pub enum ImagePackSource {
34+ User,
35+ Room { room_id: OwnedRoomId, state_key: String },
36+}
37+
38+pub struct PackImage {
39+ pub shortcode: String,
40+ pub url: OwnedMxcUri,
41+ pub body: Option<String>,
42+ /// image usage if set, else the pack's, else both
43+ pub usage: Vec<PackUsage>,
44+}
45+```
46+
47+`id` only needs to be stable for a pack across calls, e.g. `user` or `room:<room_id>:<state_key>`. Serialised field names should match the rest of macltok's normalised types. Malice's web client already has the matching TypeScript shape in `web/src/lib/types.ts` (`ImagePack`, `PackImage`).
48+
49+It would also help to get a change event when any of the three sources updates, so the picker doesn't go stale, either a dedicated event or folding it into the existing room/account mirror.
50+
51+## Related, reactions with custom emotes (MSC4027)
52+
53+A custom emote reaction uses the image's mxc URI as the `m.annotation` key, with the shortcode in `com.beeper.reaction.shortcode` so clients without the pack can show a label.
54+
55+- `toggle_reaction` only takes a key. An optional shortcode would let malice send the label.
56+- `normalise::timeline::reactions` drops the shortcode on receipt. `Reaction` could carry `shortcode: Option<String>`, taken from any sender's annotation for that key.
57+
58+Malice relaxed its own 32-char key limit for `mxc://` keys, so sending already works without the shortcode.
Modified file: crates/malice-core/src/request.rs+86 additionsโˆ’4 deletions
@@ -1,3 +1,4 @@
1+use macltok::ruma::{events::GlobalAccountDataEventType, serde::Raw};
12 use macltok::{
23 AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,
34 MessageContent, MirrorHandle, TimelineId, VerificationAction, VerificationTarget,
@@ -98,6 +99,13 @@ pub enum Request {
9899 #[serde(default)]
99100 typing: bool,
100101 },
102+ GetAccountData {
103+ event_type: String,
104+ },
105+ SetAccountData {
106+ event_type: String,
107+ content: serde_json::Value,
108+ },
101109 }
102110
103111 impl Request {
@@ -223,8 +231,10 @@ pub async fn dispatch(
223231 let room_id = parse_room_id(&room_id)?;
224232 let event_id = parse_event_id(&event_id)?;
225233 let key = key.trim();
226- if key.is_empty() || key.chars().count() > 32 {
227- return Err(CoreError::bad_request("key must be 1..32 characters"));
234+ if !valid_reaction_key(key) {
235+ return Err(CoreError::bad_request(
236+ "key must be 1..32 characters, or an mxc uri up to 255",
237+ ));
228238 }
229239 handle
230240 .toggle_reaction(TimelineId::Room(room_id), event_id, key)
@@ -277,11 +287,21 @@ pub async fn dispatch(
277287 Ok(Reply::Done)
278288 }
279289 Request::Verify { action } => {
290+ let action = match action.as_str() {
291+ "Accept" => VerificationAction::Accept,
292+ "Confirm" => VerificationAction::Confirm,
293+ "Reject" => VerificationAction::Reject,
294+ "Cancel" => VerificationAction::Cancel,
295+ _ => return Err(CoreError::bad_request("invalid verification action")),
296+ };
280297 handle.verify(action).await?;
281298 Ok(Reply::Done)
282299 }
283- Request::Avatar { media_id } => {
284- let media_id = parse_media_id(media_id)?;
300+ Request::Avatar { mxc } => {
301+ if !mxc.starts_with("mxc://") {
302+ return Err(CoreError::bad_request("invalid mxc URI"));
303+ }
304+ let media_id = parse_media_id(mxc)?;
285305 let size = MediaSize::Thumbnail {
286306 width: AVATAR_SIZE,
287307 height: AVATAR_SIZE,
@@ -317,5 +337,67 @@ pub async fn dispatch(
317337 handle.set_typing(room_id, typing).await?;
318338 Ok(Reply::Done)
319339 }
340+ Request::GetAccountData { event_type } => {
341+ let event_type = account_data_type(&event_type)?;
342+ let content = handle.global_account_data(event_type).await?;
343+ let value = match content {
344+ Some(raw) => serde_json::from_str(raw.json().get())
345+ .map_err(|_| CoreError::bad_request("account data is not valid json"))?,
346+ None => serde_json::Value::Null,
347+ };
348+ Ok(Reply::Json(value))
349+ }
350+ Request::SetAccountData {
351+ event_type,
352+ content,
353+ } => {
354+ let event_type = account_data_type(&event_type)?;
355+ if !content.is_object() {
356+ return Err(CoreError::bad_request("content must be a json object"));
357+ }
358+ let raw = serde_json::value::to_raw_value(&content)
359+ .map_err(|_| CoreError::bad_request("content is not valid json"))?;
360+ handle
361+ .set_global_account_data(event_type, Raw::from_json(raw))
362+ .await?;
363+ Ok(Reply::Done)
364+ }
365+ }
366+}
367+
368+fn parse_media_id(media_id: String) -> Result<MediaId, CoreError> {
369+ if media_id.is_empty() || media_id.len() > 512 {
370+ return Err(CoreError::bad_request("invalid media_id"));
371+ }
372+ Ok(MediaId::from(media_id))
373+}
374+
375+fn valid_reaction_key(key: &str) -> bool {
376+ if key.starts_with("mxc://") {
377+ return key.len() <= 255;
378+ }
379+ !key.is_empty() && key.chars().count() <= 32
380+}
381+
382+fn account_data_type(event_type: &str) -> Result<GlobalAccountDataEventType, CoreError> {
383+ if event_type.is_empty() || event_type.len() > 255 {
384+ return Err(CoreError::bad_request("event_type must be 1..255 bytes"));
385+ }
386+ Ok(GlobalAccountDataEventType::from(event_type))
387+}
388+
389+#[cfg(test)]
390+mod tests {
391+ use super::*;
392+
393+ #[test]
394+ fn reaction_keys() {
395+ assert!(valid_reaction_key("๐Ÿ‘"));
396+ assert!(!valid_reaction_key(""));
397+ assert!(!valid_reaction_key(&"a".repeat(33)));
398+ assert!(valid_reaction_key(
399+ "mxc://example.org/abcdef0123456789abcdef0123456789"
400+ ));
401+ assert!(!valid_reaction_key(&format!("mxc://{}", "a".repeat(250))));
320402 }
321403 }
Modified file: crates/malice-server/src/error.rs+1 additions
@@ -58,6 +58,7 @@ impl From<CoreError> for ApiError {
5858 fn from(error: CoreError) -> Self {
5959 let status = match error.kind {
6060 Failure::BadRequest => StatusCode::BAD_REQUEST,
61+ Failure::Unauthorized => StatusCode::UNAUTHORIZED,
6162 Failure::NotFound => StatusCode::NOT_FOUND,
6263 Failure::Conflict => StatusCode::CONFLICT,
6364 Failure::Busy => StatusCode::TOO_MANY_REQUESTS,
Modified file: crates/malice-server/src/routes.rs+66 additionsโˆ’21 deletions
@@ -12,7 +12,7 @@ use futures_util::StreamExt;
1212 use macltok::Retain;
1313 use malice_core::{
1414 feed::subscribe,
15- login::{LoginInput, sanitise_auth_error},
15+ login::LoginInput,
1616 request::{Reply, Request, dispatch},
1717 };
1818 use serde::{Deserialize, Serialize};
@@ -112,6 +112,17 @@ struct TypingBody {
112112 typing: bool,
113113 }
114114
115+#[derive(Debug, Deserialize)]
116+struct AccountDataQuery {
117+ event_type: String,
118+}
119+
120+#[derive(Debug, Deserialize)]
121+struct AccountDataBody {
122+ event_type: String,
123+ content: serde_json::Value,
124+}
125+
115126 #[derive(Debug, Serialize)]
116127 struct SoftLogoutInfo {
117128 user_id: String,
@@ -249,36 +260,22 @@ async fn post_login(
249260 headers: HeaderMap,
250261 body: axum::Json<LoginInput>,
251262 ) -> Response {
252- let input = match body.0.validate(state.allow_http_homeserver()) {
253- Ok(input) => input,
263+ let username = body.0.username.trim().to_owned();
264+ let password = body.0.password.clone();
265+ let login = match body.0.into_login(state.allow_http_homeserver()) {
266+ Ok(login) => login,
254267 Err(error) => return ApiError::from(error).into_response(),
255268 };
256269 let cookie_sid = session_sid(&headers);
257- let password = input.password.clone();
258270 match state
259- .login(
260- cookie_sid.as_deref(),
261- input.homeserver,
262- input.username,
263- input.password,
264- )
271+ .login(cookie_sid.as_deref(), login, &username, password)
265272 .await
266273 {
267274 Ok(user) => json_ok(
268275 &serde_json::json!({ "user_id": user.user_id }),
269276 Some(set_cookie_value(&user.sid, state.cookie_secure())),
270277 ),
271- Err(message) => {
272- let status = if message.contains("starting sync engine")
273- || message.contains("could not save the session")
274- || message.contains("another engine is using this session")
275- {
276- StatusCode::BAD_GATEWAY
277- } else {
278- StatusCode::UNAUTHORIZED
279- };
280- ApiError::new(status, sanitise_auth_error(&message, &password)).into_response()
281- }
278+ Err(error) => ApiError::from(error).into_response(),
282279 }
283280 }
284281
@@ -720,6 +717,50 @@ async fn rooms_typing(
720717 )
721718 }
722719
720+async fn get_account_data(
721+ State(state): State<AppState>,
722+ headers: HeaderMap,
723+ Query(query): Query<AccountDataQuery>,
724+) -> Response {
725+ let (user, _) = match auth(&state, &headers).await {
726+ Ok(pair) => pair,
727+ Err(error) => return error.into_response(),
728+ };
729+ respond(
730+ dispatch(
731+ &user.handle,
732+ &user.mirror,
733+ Request::GetAccountData {
734+ event_type: query.event_type,
735+ },
736+ )
737+ .await,
738+ )
739+}
740+
741+async fn set_account_data(
742+ State(state): State<AppState>,
743+ headers: HeaderMap,
744+ body: axum::Json<AccountDataBody>,
745+) -> Response {
746+ let (user, _) = match auth(&state, &headers).await {
747+ Ok(pair) => pair,
748+ Err(error) => return error.into_response(),
749+ };
750+ let body = body.0;
751+ respond(
752+ dispatch(
753+ &user.handle,
754+ &user.mirror,
755+ Request::SetAccountData {
756+ event_type: body.event_type,
757+ content: body.content,
758+ },
759+ )
760+ .await,
761+ )
762+}
763+
723764 async fn get_events(State(state): State<AppState>, headers: HeaderMap) -> Response {
724765 let (user, _) = match auth(&state, &headers).await {
725766 Ok(pair) => pair,
@@ -766,6 +807,10 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
766807 .route("/rooms/react", post(rooms_react))
767808 .route("/rooms/read", post(rooms_read))
768809 .route("/rooms/typing", post(rooms_typing))
810+ .route(
811+ "/account-data",
812+ get(get_account_data).post(set_account_data),
813+ )
769814 .route("/profile", get(get_profile))
770815 .route("/media", get(get_media))
771816 .route("/message-media", get(get_message_media))
Modified file: crates/malice-server/src/state.rs+25 additionsโˆ’20 deletions
@@ -15,6 +15,11 @@ use rand_core::{OsRng, RngCore};
1515 use sha2::{Digest, Sha256};
1616 use tokio::{sync::Mutex, task::JoinHandle, time::timeout};
1717
18+use malice_core::{
19+ error::{CoreError, Failure},
20+ login::login_error,
21+};
22+
1823 use crate::csrf::valid_sid;
1924
2025 const AUTH_TIMEOUT: Duration = Duration::from_secs(20);
@@ -180,34 +185,37 @@ impl AppState {
180185 pub async fn login(
181186 &self,
182187 cookie_sid: Option<&str>,
183- homeserver: String,
184- username: String,
188+ login: PasswordLogin,
189+ username: &str,
185190 password: String,
186- ) -> Result<Arc<UserSession>, String> {
191+ ) -> Result<Arc<UserSession>, CoreError> {
187192 if let Some(sid) = cookie_sid.filter(|sid| valid_sid(sid))
188193 && let Some(soft) = self.soft_logged_out(sid).await
189194 {
190- if username_matches(&username, &soft.user_id) {
195+ if username_matches(username, &soft.user_id) {
191196 let sessions = self.session_store(sid);
192- let session = login_with(soft.password_login(password), sessions.clone()).await?;
197+ let session = login_with(
198+ soft.password_login(password.clone()),
199+ &password,
200+ sessions.clone(),
201+ )
202+ .await?;
193203 let _init = self.inner.init_lock.lock().await;
194204 return self
195205 .start_user_session(sid.to_owned(), session, sessions)
196- .await;
206+ .await
207+ .map_err(|error| CoreError::new(Failure::Upstream, error));
197208 }
198209 self.apply_retention(&self.session_store(sid), Retain::Nothing)
199210 .await;
200211 }
201212 let sid = new_sid();
202213 let sessions = self.session_store(&sid);
203- let session = login_with(
204- PasswordLogin::new(homeserver, username, password)
205- .with_device_display_name("malice web"),
206- sessions.clone(),
207- )
208- .await?;
214+ let session = login_with(login, &password, sessions.clone()).await?;
209215 let _init = self.inner.init_lock.lock().await;
210- self.start_user_session(sid, session, sessions).await
216+ self.start_user_session(sid, session, sessions)
217+ .await
218+ .map_err(|error| CoreError::new(Failure::Upstream, error))
211219 }
212220
213221 async fn start_user_session(
@@ -338,19 +346,16 @@ fn restore_error(error: AccountError) -> RestoreError {
338346
339347 async fn login_with(
340348 login: PasswordLogin,
349+ password: &str,
341350 sessions: Arc<dyn SessionStore>,
342-) -> Result<Session, String> {
351+) -> Result<Session, CoreError> {
343352 match Session::login_password(login, sessions).await {
344353 Ok(session) => Ok(session),
345- Err(AccountError::StoreInUse) => Err("another engine is using this session".to_owned()),
346354 Err(AccountError::Unsaved { session, .. }) => session
347355 .save()
348356 .await
349- .map_err(|_| "could not save the session".to_owned()),
350- Err(error) => Err(format!(
351- "login failed: {}",
352- malice_core::login::describe_error(&error)
353- )),
357+ .map_err(|_| CoreError::new(Failure::Upstream, "could not save the session")),
358+ Err(error) => Err(login_error(&error, password)),
354359 }
355360 }
356361
Modified file: crates/malice-worker/Cargo.lockโˆ’2 deletions
@@ -1851,9 +1851,7 @@ name = "malice-core"
18511851 version = "0.1.0"
18521852 dependencies = [
18531853 "futures-util",
1854- "http",
18551854 "macltok",
1856- "ruma",
18571855 "serde",
18581856 "serde_json",
18591857 ]
Modified file: crates/malice-worker/src/account.rs+9 additionsโˆ’27 deletions
@@ -7,6 +7,11 @@ use macltok::{
77 SessionStatus, SessionStore, StateChange,
88 };
99
10+use malice_core::{
11+ error::{CoreError, Failure},
12+ login::login_error,
13+};
14+
1015 use crate::HostInner;
1116
1217 pub struct Account {
@@ -38,20 +43,6 @@ impl Account {
3843 }
3944 }
4045
41-pub struct LoginFailure {
42- pub kind: &'static str,
43- pub message: String,
44-}
45-
46-impl LoginFailure {
47- fn new(kind: &'static str, message: impl Into<String>) -> Self {
48- Self {
49- kind,
50- message: message.into(),
51- }
52- }
53-}
54-
5546 pub fn store_name(account: &str) -> String {
5647 format!("malice-{account}")
5748 }
@@ -76,25 +67,16 @@ pub fn restore_error_kind(error: &AccountError) -> (&'static str, String) {
7667
7768 pub async fn login_with(
7869 login: PasswordLogin,
70+ password: &str,
7971 sessions: Arc<dyn SessionStore>,
80-) -> Result<Session, LoginFailure> {
72+) -> Result<Session, CoreError> {
8173 match Session::login_password(login, sessions).await {
8274 Ok(session) => Ok(session),
83- Err(AccountError::StoreInUse) => Err(LoginFailure::new(
84- "store_in_use",
85- "another engine is using this session",
86- )),
8775 Err(AccountError::Unsaved { session, .. }) => session
8876 .save()
8977 .await
90- .map_err(|_| LoginFailure::new("upstream", "could not save the session")),
91- Err(error) => Err(LoginFailure::new(
92- "unauthorized",
93- format!(
94- "login failed: {}",
95- malice_core::login::describe_error(&error)
96- ),
97- )),
78+ .map_err(|_| CoreError::new(Failure::Upstream, "could not save the session")),
79+ Err(error) => Err(login_error(&error, password)),
9880 }
9981 }
10082
Modified file: crates/malice-worker/src/lib.rs+11 additionsโˆ’14 deletions
@@ -9,8 +9,8 @@ use std::rc::Rc;
99 use std::sync::Arc;
1010
1111 use macltok::{
12- EncryptedSessionStore, MediaId, MediaLocation, MediaSize, PasswordLogin, Restore, Retain,
13- Session, SessionStore, StoreConfig,
12+ EncryptedSessionStore, MediaId, MediaLocation, MediaSize, Restore, Retain, Session,
13+ SessionStore, StoreConfig,
1414 };
1515 use malice_core::error::CoreError;
1616 use malice_core::feed::WireEvent;
@@ -114,29 +114,26 @@ impl Host {
114114 username,
115115 password,
116116 };
117- let input = match input.validate(true) {
118- Ok(input) => input,
117+ let username = input.username.trim().to_owned();
118+ let password = input.password.clone();
119+ let login = match input.into_login(true) {
120+ Ok(login) => login,
119121 Err(error) => return Err(core_error_value(error)),
120122 };
121- let password = input.password.clone();
122123 let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(
123124 StoreConfig::indexeddb(store_name(&account), passphrase),
124125 ));
125126 let login = match Session::restore(sessions.clone()).await {
126127 Ok(Some(Restore::SoftLoggedOut(soft)))
127- if username_matches(&input.username, &soft.user_id) =>
128+ if username_matches(&username, &soft.user_id) =>
128129 {
129- soft.password_login(input.password)
130+ soft.password_login(password.clone())
130131 }
131- _ => PasswordLogin::new(input.homeserver, input.username, input.password)
132- .with_device_display_name("malice web"),
132+ _ => login,
133133 };
134- let session = match login_with(login, sessions.clone()).await {
134+ let session = match login_with(login, &password, sessions.clone()).await {
135135 Ok(session) => session,
136- Err(failure) => {
137- let message = malice_core::login::sanitise_auth_error(&failure.message, &password);
138- return Err(error_value(failure.kind, message));
139- }
136+ Err(error) => return Err(core_error_value(error)),
140137 };
141138 match self.0.start_account(account, session, sessions).await {
142139 Ok(user_id) => Ok(to_js_value(&serde_json::json!({ "user_id": user_id }))),
Modified file: crates/malice-worker/src/wire.rs+1 additions
@@ -14,6 +14,7 @@ pub fn error_value(kind: &str, message: impl AsRef<str>) -> JsValue {
1414 pub fn failure_kind(failure: Failure) -> &'static str {
1515 match failure {
1616 Failure::BadRequest => "bad_request",
17+ Failure::Unauthorized => "unauthorized",
1718 Failure::NotFound => "not_found",
1819 Failure::Conflict => "conflict",
1920 Failure::Busy => "busy",
Modified file: web/package-lock.json+53 additions
@@ -8,6 +8,8 @@
88 "name": "malice-web",
99 "version": "0.1.0",
1010 "dependencies": {
11+ "@twemoji/svg": "^15.0.0",
12+ "emojibase-data": "^18.0.0",
1113 "svelte": "^5.39.0"
1214 },
1315 "devDependencies": {
@@ -15,6 +17,7 @@
1517 "@storybook/addon-mcp": "^10.6.1",
1618 "@storybook/svelte-vite": "^10.6.1",
1719 "@sveltejs/vite-plugin-svelte": "^4.0.4",
20+ "@types/node": "^22.20.5",
1821 "storybook": "^10.6.1",
1922 "typescript": "~6.0.3",
2023 "vite": "^5.4.11"
@@ -1993,6 +1996,12 @@
19931996 }
19941997 }
19951998 },
1999+ "node_modules/@twemoji/svg": {
2000+ "version": "15.0.0",
2001+ "resolved": "https://registry.npmjs.org/@twemoji/svg/-/svg-15.0.0.tgz",
2002+ "integrity": "sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==",
2003+ "license": "MIT"
2004+ },
19962005 "node_modules/@tybys/wasm-util": {
19972006 "version": "0.10.4",
19982007 "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
@@ -2042,6 +2051,16 @@
20422051 "dev": true,
20432052 "license": "MIT"
20442053 },
2054+ "node_modules/@types/node": {
2055+ "version": "22.20.5",
2056+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
2057+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
2058+ "dev": true,
2059+ "license": "MIT",
2060+ "dependencies": {
2061+ "undici-types": "~6.21.0"
2062+ }
2063+ },
20452064 "node_modules/@types/react": {
20462065 "version": "19.3.0",
20472066 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -2383,6 +2402,33 @@
23832402 "dev": true,
23842403 "license": "MIT"
23852404 },
2405+ "node_modules/emojibase": {
2406+ "version": "18.0.0",
2407+ "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-18.0.0.tgz",
2408+ "integrity": "sha512-5PG+EEt5zQ9qvqdHZw61+T9iz96g+r621QS/w8jEzPg+3mjzAdjl8LPLc+e9ABv/yboNfMwEair7aK9qKnwAbQ==",
2409+ "license": "MIT",
2410+ "peer": true,
2411+ "engines": {
2412+ "node": ">=20.0.0"
2413+ },
2414+ "funding": {
2415+ "type": "ko-fi",
2416+ "url": "https://ko-fi.com/milesjohnson"
2417+ }
2418+ },
2419+ "node_modules/emojibase-data": {
2420+ "version": "18.0.0",
2421+ "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-18.0.0.tgz",
2422+ "integrity": "sha512-TPPRRYv2zPd0jKI+pTpMquwWNZXYAsC3IVsCMeg2X8y8fEzKwOks4/5bz0YavF5r6d71aGPpb6oDnUEwSLLMoA==",
2423+ "license": "MIT",
2424+ "funding": {
2425+ "type": "ko-fi",
2426+ "url": "https://ko-fi.com/milesjohnson"
2427+ },
2428+ "peerDependencies": {
2429+ "emojibase": "*"
2430+ }
2431+ },
23862432 "node_modules/esbuild": {
23872433 "version": "0.28.2",
23882434 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -3205,6 +3251,13 @@
32053251 "node": ">=14.17"
32063252 }
32073253 },
3254+ "node_modules/undici-types": {
3255+ "version": "6.21.0",
3256+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
3257+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
3258+ "dev": true,
3259+ "license": "MIT"
3260+ },
32083261 "node_modules/unplugin": {
32093262 "version": "2.3.11",
32103263 "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
Modified file: web/package.json+4 additionsโˆ’1 deletions
@@ -14,13 +14,16 @@
1414 "build-storybook": "storybook build && chmod -R u+w storybook-static"
1515 },
1616 "dependencies": {
17+ "@twemoji/svg": "^15.0.0",
18+ "emojibase-data": "^18.0.0",
1719 "svelte": "^5.39.0"
1820 },
1921 "devDependencies": {
20- "@sveltejs/vite-plugin-svelte": "^4.0.4",
2122 "@storybook/addon-docs": "^10.6.1",
2223 "@storybook/addon-mcp": "^10.6.1",
2324 "@storybook/svelte-vite": "^10.6.1",
25+ "@sveltejs/vite-plugin-svelte": "^4.0.4",
26+ "@types/node": "^22.20.5",
2427 "storybook": "^10.6.1",
2528 "typescript": "~6.0.3",
2629 "vite": "^5.4.11"
Modified file: web/src/App.svelte+25 additionsโˆ’1 deletions
@@ -43,13 +43,15 @@
4343 } from './lib/store';
4444 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
4545 import type { PendingMessage } from './lib/pending';
46+ import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
4647 import { notifyTyping, stopTyping } from './lib/typing';
4748 import { formatDay } from './lib/format';
4849 import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
49- import type { MemberSummary, OwnProfile, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
50+ import type { MemberSummary, OwnProfile, QuickReaction, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
5051
5152 let roomsLoading = false;
5253 let booted = false;
54+ let wasOnline = false;
5355 let search = '';
5456 let sidebarOpen = false;
5557 let membersOpen = false;
@@ -84,6 +86,12 @@
8486 let spaceChildrenError: string | null = null;
8587 let avatarByUser: Record<string, string | null> = {};
8688
89+ // account data is read from the local cache, which is empty on a fresh device until the first sync lands
90+ $: {
91+ const online = $connection === 'online';
92+ if (online && !wasOnline) void loadQuickReactions();
93+ wasOnline = online;
94+ }
8795 $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
8896 $: spaceRooms = $rooms.filter((r) => r.space);
8997 $: invites = $rooms.filter((r) => r.membership === 'Invited');
@@ -204,6 +212,7 @@
204212 connectEvents();
205213 void loadProfile();
206214 void loadEncryption();
215+ void loadQuickReactions();
207216 } else {
208217 softLogout.set(session.soft_logout ?? null);
209218 }
@@ -258,6 +267,7 @@
258267 connectEvents();
259268 void loadProfile();
260269 void loadEncryption();
270+ void loadQuickReactions();
261271 }
262272
263273 async function doLogout(): Promise<void> {
@@ -501,6 +511,14 @@
501511 }
502512 }
503513
514+ async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
515+ try {
516+ await saveQuickReactions(list);
517+ } catch (e) {
518+ pushBanner(e instanceof Error ? e.message : 'Could not save quick reactions');
519+ }
520+ }
521+
504522 async function paginate(): Promise<void> {
505523 const roomId = $selectedRoomId;
506524 if (!roomId || paginateBusy) return;
@@ -1066,6 +1084,9 @@
10661084 senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
10671085 {reacting}
10681086 onReact={(eventId, key) => void react(eventId, key)}
1087+ quickReactions={$quickReactions}
1088+ packs={[]}
1089+ onQuickChange={(list) => void changeQuickReactions(list)}
10691090 />
10701091 {/if}
10711092 {/each}
@@ -1078,6 +1099,9 @@
10781099 senderAvatar={senderAvatarFor($sessionUser ?? '')}
10791100 {reacting}
10801101 onReact={(eventId, key) => void react(eventId, key)}
1102+ quickReactions={$quickReactions}
1103+ packs={[]}
1104+ onQuickChange={(list) => void changeQuickReactions(list)}
10811105 />
10821106 {#if p.status === 'failed'}
10831107 <div class="pending-actions">
Added file: web/src/lib/Emoji.svelte+32 additions
@@ -0,0 +1,32 @@
1+<script lang="ts">
2+ import { twemojiUrl } from './twemoji';
3+
4+ export let emoji: string;
5+ export let size: number | string = 20;
6+ export let label: string | null = null;
7+
8+ let failed = false;
9+ $: emoji, (failed = false);
10+ $: dim = typeof size === 'number' ? `${size}px` : size;
11+ $: align = typeof size === 'number' ? 'middle' : '-0.25em';
12+</script>
13+
14+{#if failed}
15+ <span class="native" style={`font-size:${dim}`} aria-label={label ?? undefined}>{emoji}</span>
16+{:else}
17+ <img
18+ class="emoji"
19+ src={twemojiUrl(emoji)}
20+ alt={emoji}
21+ aria-label={label ?? undefined}
22+ style={`width:${dim};height:${dim};vertical-align:${align}`}
23+ draggable="false"
24+ loading="lazy"
25+ on:error={() => (failed = true)}
26+ />
27+{/if}
28+
29+<style>
30+ .emoji { display: inline-block; object-fit: contain; }
31+ .native { display: inline-block; line-height: 1; vertical-align: middle; }
32+</style>
Added file: web/src/lib/EmojiText.svelte+12 additions
@@ -0,0 +1,12 @@
1+<script lang="ts">
2+ import Emoji from './Emoji.svelte';
3+ import { isJumbo, segmentEmoji } from './twemoji';
4+
5+ export let text: string;
6+ export let jumbo = false;
7+
8+ $: segments = segmentEmoji(text);
9+ $: size = jumbo && isJumbo(segments) ? '2.4em' : '1.25em';
10+</script>
11+
12+{#each segments as s}{#if s.emoji}<Emoji emoji={s.text} {size} />{:else}{s.text}{/if}{/each}
Modified file: web/src/lib/MessageRow.stories.ts+55 additionsโˆ’5 deletions
@@ -1,7 +1,7 @@
11 import type { Meta, StoryObj } from '@storybook/svelte-vite';
2-import { fn } from 'storybook/test';
2+import { expect, fn, userEvent } from 'storybook/test';
33 import MessageRow from './MessageRow.svelte';
4-import type { TimelineContent, TimelineEvent } from './types';
4+import type { QuickReaction, TimelineContent, TimelineEvent } from './types';
55
66 const alice = { user_id: '@alice:example.org', display_name: 'Alice', avatar: null, name_ambiguous: false };
77
@@ -32,6 +32,12 @@ function text(body: string): TimelineContent {
3232 };
3333 }
3434
35+const quick: QuickReaction[] = [
36+ { key: '๐Ÿ‘', shortcode: 'thumbsup' },
37+ { key: '๐ŸŽ‰', shortcode: 'tada' },
38+ { key: 'mxc://example.org/rabbit', shortcode: 'rabbit' }
39+];
40+
3541 const meta = {
3642 title: 'Timeline/MessageRow',
3743 component: MessageRow,
@@ -40,7 +46,10 @@ const meta = {
4046 event: event(text('Curiouser and curiouser!')),
4147 showSender: true,
4248 reacting: false,
43- onReact: fn()
49+ onReact: fn(),
50+ quickReactions: quick,
51+ packs: [],
52+ onQuickChange: fn()
4453 }
4554 } satisfies Meta<typeof MessageRow>;
4655
@@ -48,6 +57,29 @@ export default meta;
4857 type Story = StoryObj<typeof meta>;
4958
5059 export const Text: Story = {};
60+export const ActionsOnSelect: Story = {
61+ play: async ({ args, canvas }) => {
62+ const toolbar = canvas.getByRole('toolbar');
63+ await expect(toolbar).not.toBeVisible();
64+ await userEvent.click(canvas.getByText('Curiouser and curiouser!'));
65+ await expect(toolbar).toBeVisible();
66+ await userEvent.click(canvas.getByLabelText('React :tada: to message from Alice'));
67+ await expect(args.onReact).toHaveBeenCalledWith('$event1', '๐ŸŽ‰');
68+ await userEvent.keyboard('{Escape}');
69+ (document.activeElement as HTMLElement).blur();
70+ await userEvent.unhover(canvas.getByText('Curiouser and curiouser!'));
71+ await expect(toolbar).not.toBeVisible();
72+ }
73+};
74+export const PickerFromToolbar: Story = {
75+ play: async ({ args, canvas }) => {
76+ await userEvent.click(canvas.getByText('Curiouser and curiouser!'));
77+ await userEvent.click(canvas.getByRole('button', { name: 'Add reaction to message from Alice' }));
78+ await userEvent.click(await canvas.findByLabelText('party popper', {}, { timeout: 10000 }));
79+ await expect(args.onReact).toHaveBeenCalledWith('$event1', '๐ŸŽ‰');
80+ await expect(canvas.queryByRole('dialog')).toBeNull();
81+ }
82+};
5183 export const Continuation: Story = { args: { showSender: false } };
5284 export const WithLink: Story = { args: { event: event(text('Notes are at https://example.org/rabbit-hole')) } };
5385 export const Reactions: Story = {
@@ -55,7 +87,8 @@ export const Reactions: Story = {
5587 event: event(text('Tea party at six'), {
5688 reactions: [
5789 { key: '๐ŸŽ‰', count: 3, own: true, senders: [] },
58- { key: '๐Ÿ˜ฎ', count: 1, own: false, senders: [] }
90+ { key: '๐Ÿ˜ฎ', count: 1, own: false, senders: [] },
91+ { key: 'mxc://example.org/rabbit', count: 2, own: false, senders: [] }
5992 ]
6093 })
6194 }
@@ -69,9 +102,26 @@ export const OwnFailed: Story = {
69102 export const UnableToDecrypt: Story = {
70103 args: { event: event({ kind: 'UnableToDecrypt', cause: 'Unknown' }) }
71104 };
72-export const Redacted: Story = { args: { event: event({ kind: 'Redacted' }) } };
105+export const Redacted: Story = {
106+ args: { event: event({ kind: 'Redacted' }) },
107+ play: async ({ canvas }) => {
108+ await expect(canvas.queryByRole('toolbar')).toBeNull();
109+ }
110+};
73111 export const Membership: Story = {
74112 args: {
75113 event: event({ kind: 'Membership', user_id: '@hatter:example.org', display_name: 'Hatter', change: 'Joined' })
76114 }
77115 };
116+export const EmojiInBody: Story = {
117+ args: { event: event(text('Tea time ๐Ÿซ– with ๐Ÿ‘ฉโ€๐Ÿ’ป and ๐Ÿ‡ณ๐Ÿ‡ฟ at 1๏ธโƒฃ sharp')) }
118+};
119+export const JumboEmoji: Story = {
120+ args: { event: event(text('๐ŸŽ‰๐ŸŽ‰')) },
121+ play: async ({ canvasElement }) => {
122+ const imgs = canvasElement.querySelectorAll<HTMLImageElement>('.text img');
123+ await expect(imgs).toHaveLength(2);
124+ await expect(imgs[0]).toHaveAttribute('alt', '๐ŸŽ‰');
125+ await expect(imgs[0]).toHaveAttribute('draggable', 'false');
126+ }
127+};
Modified file: web/src/lib/MessageRow.svelte+140 additionsโˆ’35 deletions
@@ -1,7 +1,11 @@
11 <script lang="ts">
22 import MediaAttachmentView from './MediaAttachmentView.svelte';
33 import MxcAvatar from './MxcAvatar.svelte';
4- import type { SendState, TimelineContent, TimelineEvent } from './types';
4+ import Emoji from './Emoji.svelte';
5+ import EmojiText from './EmojiText.svelte';
6+ import ReactionPicker from './ReactionPicker.svelte';
7+ import { mediaUrl } from './api';
8+ import type { ImagePack, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';
59 import { senderLabel } from './types';
610 import { formatClock, formatDay, initialsOf, senderHue } from './format';
711 import { findUrls } from './urls';
@@ -12,8 +16,19 @@
1216 export let reacting: boolean;
1317 export let onReact: (eventId: string, key: string) => void;
1418 export let senderAvatar: string | null = null;
19+ export let quickReactions: QuickReaction[] = [];
20+ export let packs: ImagePack[] = [];
21+ export let onQuickChange: (list: QuickReaction[]) => void = () => {};
1522
16- const QUICK_KEYS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐ŸŽ‰', '๐Ÿ˜ฎ', '๐Ÿ˜ข'];
23+ const PICKER_WIDTH = 320;
24+ const PICKER_HEIGHT = 380;
25+ const PICKER_MARGIN = 8;
26+
27+ let row: HTMLDivElement;
28+ let pickerPop: HTMLDivElement | undefined;
29+ let selected = false;
30+ let pickerOpen = false;
31+ let pickerStyle = '';
1732
1833 const MEMBERSHIP_LABELS: Record<string, string> = {
1934 Joined: 'joined the room',
@@ -44,6 +59,44 @@
4459 return '';
4560 }
4661
62+ function toggleSelected(e: MouseEvent) {
63+ if (!interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
64+ selected = !selected;
65+ }
66+
67+ function deselectOutside(e: PointerEvent) {
68+ if (selected && !pickerOpen && !row.contains(e.target as Node)) selected = false;
69+ }
70+
71+ function openPicker(e: MouseEvent) {
72+ if (pickerOpen) {
73+ pickerOpen = false;
74+ return;
75+ }
76+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
77+ const height = Math.min(PICKER_HEIGHT, window.innerHeight - 2 * PICKER_MARGIN);
78+ const below = window.innerHeight - rect.bottom - PICKER_MARGIN >= height;
79+ const top = below ? rect.bottom + 4 : Math.max(PICKER_MARGIN, rect.top - height - 4);
80+ const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;
81+ const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));
82+ pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;
83+ selected = true;
84+ pickerOpen = true;
85+ }
86+
87+ function closePicker() {
88+ pickerOpen = false;
89+ }
90+
91+ function pick(key: string) {
92+ pickerOpen = false;
93+ onReact(event.event_id ?? '', key);
94+ }
95+
96+ function closeOnScroll(e: Event) {
97+ if (pickerOpen && !pickerPop?.contains(e.target as Node)) pickerOpen = false;
98+ }
99+
47100 function actorLabel(userId: string, name: string | null): string {
48101 return name ?? userId;
49102 }
@@ -106,7 +159,59 @@
106159 }
107160 </script>
108161
109-<div class="msg" class:own={event.own} class:system={!interactive} data-item={itemId}>
162+<svelte:window
163+ on:pointerdown={deselectOutside}
164+ on:keydown={(e) => e.key === 'Escape' && (selected = false)}
165+ on:scroll|capture={closeOnScroll}
166+ on:resize={closePicker}
167+/>
168+
169+<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
170+<div
171+ class="msg"
172+ class:own={event.own}
173+ class:system={!interactive}
174+ class:selected
175+ data-item={itemId}
176+ bind:this={row}
177+ on:click={toggleSelected}
178+>
179+ {#if interactive && event.event_id}
180+ <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
181+ {#each quickReactions as r (r.key)}
182+ {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
183+ <button
184+ type="button"
185+ title={`React ${label}`}
186+ aria-label={`React ${label} to message from ${displayName}`}
187+ disabled={reacting}
188+ on:click={() => onReact(event.event_id ?? '', r.key)}
189+ >
190+ {#if r.key.startsWith('mxc://')}
191+ <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
192+ {:else}
193+ <Emoji emoji={r.key} size={18} />
194+ {/if}
195+ </button>
196+ {/each}
197+ <button
198+ type="button"
199+ title="Add reaction"
200+ aria-label={`Add reaction to message from ${displayName}`}
201+ aria-haspopup="dialog"
202+ aria-expanded={pickerOpen}
203+ on:pointerdown|stopPropagation
204+ on:click={openPicker}
205+ >
206+ โ˜บ+
207+ </button>
208+ </div>
209+ {#if pickerOpen}
210+ <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
211+ <ReactionPicker {packs} quick={quickReactions} onPick={(key) => pick(key)} {onQuickChange} onClose={closePicker} />
212+ </div>
213+ {/if}
214+ {/if}
110215 {#if showSender}
111216 {#if senderAvatar}
112217 <MxcAvatar mxc={senderAvatar} label={name} size={34} />
@@ -141,7 +246,7 @@
141246 {#if event.reply_to}
142247 <div class="reply-quote">
143248 {#if event.reply_to.details.kind === 'Ready'}
144- Replying to {senderLabel(event.reply_to.details.sender)}: {replyPreview(event.reply_to.details.content)}
249+ Replying to {senderLabel(event.reply_to.details.sender)}: <EmojiText text={replyPreview(event.reply_to.details.content)} />
145250 {:else if event.reply_to.details.kind === 'Pending'}
146251 Loading replyโ€ฆ
147252 {:else}
@@ -153,7 +258,7 @@
153258 {#if event.content.kind === 'Message'}
154259 {@const m = event.content.message}
155260 <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
156- {#if m.kind === 'Emote'}* {displayName} {/if}{m.body}
261+ {#if m.kind === 'Emote'}* {displayName} {/if}<EmojiText text={m.body} jumbo={m.kind === 'Text'} />
157262 </p>
158263 {#if m.media}
159264 <MediaAttachmentView attachment={m.media} />
@@ -170,7 +275,7 @@
170275 </div>
171276 {/if}
172277 {:else if event.content.kind === 'Sticker'}
173- {#if event.content.body}<p class="text">{event.content.body}</p>{/if}
278+ {#if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}
174279 {#if event.content.media}
175280 <MediaAttachmentView attachment={event.content.media} />
176281 {/if}
@@ -191,13 +296,17 @@
191296 disabled={!event.event_id || reacting}
192297 on:click={() => event.event_id && onReact(event.event_id, r.key)}
193298 >
194- <span aria-hidden="true">{r.key}</span>
299+ {#if r.key.startsWith('mxc://')}
300+ <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
301+ {:else}
302+ <Emoji emoji={r.key} size={16} />
303+ {/if}
195304 <span class="count">{r.count}</span>
196305 </button>
197306 {/each}
198307 </div>
199308 {/if}
200- {#if interactive}
309+ {#if interactive && (sendLabel(event.send_state) || event.read_by.length > 0)}
201310 <div class="msg-foot">
202311 {#if sendLabel(event.send_state)}
203312 <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
@@ -205,31 +314,16 @@
205314 {#if event.read_by.length > 0}
206315 <span class="readby" title={event.read_by.join(', ')}>โœ“โœ“ {event.read_by.length}</span>
207316 {/if}
208- {#if event.event_id}
209- <span class="quick-react" role="group" aria-label={`React to message from ${displayName}`}>
210- {#each QUICK_KEYS as key (key)}
211- <button
212- type="button"
213- title={`React ${key}`}
214- aria-label={`React ${key} to message from ${displayName}`}
215- disabled={reacting}
216- on:click={() => onReact(event.event_id ?? '', key)}
217- >
218- {key}
219- </button>
220- {/each}
221- </span>
222- {/if}
223317 </div>
224318 {/if}
225319 </div>
226320 </div>
227321
228322 <style>
229- .msg { display: flex; gap: 10px; padding: 3px 0; }
230- .msg:hover { background: rgba(255, 255, 255, 0.018); }
231- .msg:hover .hover-time { opacity: 1; }
232- .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
323+ .msg { position: relative; display: flex; gap: 10px; padding: 3px 0; }
324+ .msg:hover, .msg.selected { background: rgba(255, 255, 255, 0.018); }
325+ .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
326+ .msg-foot { display: flex; align-items: center; gap: 8px; }
233327 .sendstate { font-size: 11px; color: var(--muted); }
234328 .sendstate.failed { color: var(--danger); }
235329 .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
@@ -289,18 +383,29 @@
289383 }
290384 .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
291385 .reaction:disabled { cursor: default; opacity: 0.85; }
292- .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
293386 .readby { font-size: 11px; color: var(--muted); }
294- .msg:hover .quick-react, .msg:focus-within .quick-react { display: inline-flex; }
295- .quick-react button {
387+ .actions {
388+ position: absolute; top: -14px; right: 12px; z-index: 2;
389+ display: flex; gap: 2px;
390+ padding: 2px;
391+ background: var(--bg-soft);
392+ border: 1px solid var(--border);
393+ border-radius: 8px;
394+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
395+ opacity: 0;
396+ pointer-events: none;
397+ }
398+ .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; pointer-events: auto; }
399+ .actions button {
296400 background: transparent;
297401 border: 1px solid transparent;
298402 border-radius: 6px;
299403 cursor: pointer;
300- font-size: 13px;
301- padding: 0 3px;
302- opacity: 0.75;
404+ font-size: 14px;
405+ padding: 1px 5px;
303406 }
304- .quick-react button:hover:not(:disabled) { opacity: 1; border-color: var(--border); background: var(--bg-soft); }
305- .quick-react button:disabled { opacity: 0.4; cursor: default; }
407+ .actions button:hover:not(:disabled) { border-color: var(--border); background: rgba(255, 255, 255, 0.06); }
408+ .actions button:disabled { opacity: 0.4; cursor: default; }
409+ .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
410+ .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }
306411 </style>
Added file: web/src/lib/ReactionPicker.stories.ts+110 additions
@@ -0,0 +1,110 @@
1+import type { Meta, StoryObj } from '@storybook/svelte-vite';
2+import { expect, fn, userEvent, waitFor } from 'storybook/test';
3+import ReactionPicker from './ReactionPicker.svelte';
4+import type { ImagePack, QuickReaction } from './types';
5+
6+const packs: ImagePack[] = [
7+ {
8+ id: 'user',
9+ display_name: 'My emotes',
10+ avatar_url: null,
11+ source: { kind: 'User' },
12+ images: [
13+ { shortcode: 'rabbit', url: 'mxc://example.org/rabbit', body: 'White rabbit', usage: ['emoticon'] },
14+ { shortcode: 'teapot', url: 'mxc://example.org/teapot', body: null, usage: ['emoticon', 'sticker'] }
15+ ]
16+ },
17+ {
18+ id: 'room',
19+ display_name: 'Tea party stickers',
20+ avatar_url: null,
21+ source: { kind: 'Room', room_id: '!tea:example.org', state_key: 'tea' },
22+ images: [{ shortcode: 'dormouse', url: 'mxc://example.org/dormouse', body: null, usage: ['sticker'] }]
23+ }
24+];
25+
26+const quick: QuickReaction[] = [
27+ { key: '๐Ÿ‘', shortcode: 'thumbsup' },
28+ { key: '๐ŸŽ‰', shortcode: 'tada' }
29+];
30+
31+const meta = {
32+ title: 'Timeline/ReactionPicker',
33+ component: ReactionPicker,
34+ args: { packs, quick, onPick: fn(), onQuickChange: fn(), onClose: fn() }
35+} satisfies Meta<typeof ReactionPicker>;
36+
37+export default meta;
38+type Story = StoryObj<typeof meta>;
39+
40+export const Default: Story = {
41+ play: async ({ canvas }) => {
42+ await expect(canvas.getByRole('searchbox', { name: 'Search emoji' })).toHaveFocus();
43+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeVisible();
44+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });
45+ }
46+};
47+
48+export const Search: Story = {
49+ play: async ({ canvas }) => {
50+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });
51+ await userEvent.type(canvas.getByRole('searchbox'), 'rabbit');
52+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeVisible();
53+ await expect(canvas.queryByRole('button', { name: ':teapot:' })).toBeNull();
54+ await expect(canvas.queryByRole('button', { name: 'grinning face' })).toBeNull();
55+ await expect(canvas.getByRole('button', { name: 'rabbit face' })).toBeVisible();
56+ }
57+};
58+
59+export const NoMatches: Story = {
60+ play: async ({ canvas }) => {
61+ await waitFor(() => expect(canvas.getByLabelText('grinning face')).toBeVisible(), { timeout: 10000 });
62+ await userEvent.type(canvas.getByRole('searchbox'), 'zzzzqq');
63+ await expect(canvas.getByText(/No emoji match/)).toBeVisible();
64+ }
65+};
66+
67+export const PickEmoji: Story = {
68+ play: async ({ args, canvas }) => {
69+ await userEvent.click(await canvas.findByLabelText('party popper', {}, { timeout: 10000 }));
70+ await expect(args.onPick).toHaveBeenCalledWith('๐ŸŽ‰', 'party');
71+ }
72+};
73+
74+export const PickCustom: Story = {
75+ play: async ({ args, canvas }) => {
76+ await userEvent.click(canvas.getByRole('button', { name: ':rabbit:' }));
77+ await expect(args.onPick).toHaveBeenCalledWith('mxc://example.org/rabbit', 'rabbit');
78+ }
79+};
80+
81+export const CustomiseToggles: Story = {
82+ play: async ({ args, canvas }) => {
83+ await userEvent.click(canvas.getByRole('button', { name: 'Customise quick reactions' }));
84+ await expect(canvas.getByRole('group', { name: 'Quick reactions' })).toBeVisible();
85+ await userEvent.click(canvas.getByRole('button', { name: ':rabbit:' }));
86+ await expect(args.onQuickChange).toHaveBeenLastCalledWith([...quick, { key: 'mxc://example.org/rabbit', shortcode: 'rabbit' }]);
87+ await userEvent.click(canvas.getByRole('button', { name: 'Remove :thumbsup: from quick reactions' }));
88+ await expect(args.onQuickChange).toHaveBeenLastCalledWith([quick[1]]);
89+ await expect(args.onPick).not.toHaveBeenCalled();
90+ }
91+};
92+
93+const fullQuick: QuickReaction[] = ['๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜†', '๐Ÿ˜…', '๐Ÿ˜‚', '๐Ÿคฃ'].map((key) => ({ key, shortcode: null }));
94+
95+export const CustomiseFull: Story = {
96+ args: { quick: fullQuick },
97+ play: async ({ args, canvas }) => {
98+ await userEvent.click(canvas.getByRole('button', { name: 'Customise quick reactions' }));
99+ await expect(canvas.getByText('8/8')).toBeVisible();
100+ await expect(canvas.getByRole('button', { name: ':rabbit:' })).toBeDisabled();
101+ await expect(args.onQuickChange).not.toHaveBeenCalled();
102+ }
103+};
104+
105+export const EscapeCloses: Story = {
106+ play: async ({ args }) => {
107+ await userEvent.keyboard('{Escape}');
108+ await expect(args.onClose).toHaveBeenCalled();
109+ }
110+};
Added file: web/src/lib/ReactionPicker.svelte+247 additions
@@ -0,0 +1,247 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { mediaUrl } from './api';
4+ import Emoji from './Emoji.svelte';
5+ import { loadEmoji, searchEmoji } from './emoji';
6+ import type { EmojiGroup } from './emoji';
7+ import { MAX_QUICK_REACTIONS } from './types';
8+ import type { ImagePack, PackImage, QuickReaction } from './types';
9+
10+ export let packs: ImagePack[];
11+ export let quick: QuickReaction[];
12+ export let onPick: (key: string, shortcode: string | null) => void;
13+ export let onQuickChange: (list: QuickReaction[]) => void;
14+ export let onClose: () => void;
15+
16+ let root: HTMLDivElement;
17+ let input: HTMLInputElement;
18+ let query = '';
19+ let customising = false;
20+ let groups: EmojiGroup[] | null = null;
21+ let loadFailed = false;
22+
23+ onMount(() => {
24+ input.focus();
25+ loadEmoji().then(
26+ (g) => (groups = g),
27+ () => (loadFailed = true)
28+ );
29+ });
30+
31+ $: q = query.trim().toLowerCase().replace(/^:|:$/g, '');
32+ $: filteredPacks = packs
33+ .map((pack) => ({ pack, images: pack.images.filter((i) => imageMatches(i, q)) }))
34+ .filter((p) => p.images.length > 0);
35+ $: filteredGroups = searchEmoji(groups ?? [], q);
36+ $: full = quick.length >= MAX_QUICK_REACTIONS;
37+ $: nothingFound = q !== '' && groups !== null && filteredPacks.length === 0 && filteredGroups.length === 0;
38+
39+ function imageMatches(image: PackImage, needle: string): boolean {
40+ if (!needle) return true;
41+ return image.shortcode.toLowerCase().includes(needle) || (image.body ?? '').toLowerCase().includes(needle);
42+ }
43+
44+ function isQuick(key: string): boolean {
45+ return quick.some((r) => r.key === key);
46+ }
47+
48+ function choose(key: string, shortcode: string | null): void {
49+ if (!customising) {
50+ onPick(key, shortcode);
51+ return;
52+ }
53+ if (isQuick(key)) onQuickChange(quick.filter((r) => r.key !== key));
54+ else if (!full) onQuickChange([...quick, { key, shortcode }]);
55+ }
56+
57+ function quickLabel(r: QuickReaction): string {
58+ return r.shortcode ? `:${r.shortcode}:` : r.key;
59+ }
60+
61+ function onKeydown(e: KeyboardEvent): void {
62+ if (e.key !== 'Escape') return;
63+ e.stopPropagation();
64+ onClose();
65+ }
66+
67+ function closeOutside(e: PointerEvent): void {
68+ if (!root.contains(e.target as Node)) onClose();
69+ }
70+</script>
71+
72+<svelte:window on:pointerdown={closeOutside} />
73+
74+<div
75+ class="picker"
76+ role="dialog"
77+ tabindex="-1"
78+ aria-label={customising ? 'Customise quick reactions' : 'Pick a reaction'}
79+ bind:this={root}
80+ on:keydown={onKeydown}
81+>
82+ <input
83+ class="search"
84+ type="search"
85+ placeholder="Search emoji"
86+ aria-label="Search emoji"
87+ autocomplete="off"
88+ bind:this={input}
89+ bind:value={query}
90+ />
91+
92+ {#if customising}
93+ <div class="quick" role="group" aria-label="Quick reactions">
94+ <div class="quick-head">
95+ <span>Quick reactions</span>
96+ <span class="muted">{quick.length}/{MAX_QUICK_REACTIONS}</span>
97+ </div>
98+ <div class="quick-row">
99+ {#each quick as r (r.key)}
100+ <span class="quick-item">
101+ {#if r.key.startsWith('mxc://')}
102+ <img src={mediaUrl(r.key) ?? ''} alt={quickLabel(r)} />
103+ {:else}
104+ <Emoji emoji={r.key} size={18} />
105+ {/if}
106+ <button
107+ type="button"
108+ class="remove"
109+ aria-label={`Remove ${quickLabel(r)} from quick reactions`}
110+ on:click={() => onQuickChange(quick.filter((x) => x.key !== r.key))}
111+ >
112+ ร—
113+ </button>
114+ </span>
115+ {:else}
116+ <span class="muted">None. Pick emoji below to add them.</span>
117+ {/each}
118+ </div>
119+ </div>
120+ {/if}
121+
122+ <div class="scroll" aria-busy={groups === null && !loadFailed}>
123+ {#each filteredPacks as { pack, images } (pack.id)}
124+ <section class="group" aria-label={pack.display_name}>
125+ <h3>{pack.display_name}</h3>
126+ <div class="grid">
127+ {#each images as image (image.url + image.shortcode)}
128+ <button
129+ type="button"
130+ class="cell"
131+ class:chosen={customising && isQuick(image.url)}
132+ title={`:${image.shortcode}:`}
133+ aria-label={`:${image.shortcode}:`}
134+ aria-pressed={customising ? isQuick(image.url) : undefined}
135+ disabled={customising && full && !isQuick(image.url)}
136+ on:click={() => choose(image.url, image.shortcode)}
137+ >
138+ <img src={mediaUrl(image.url) ?? ''} alt="" loading="lazy" />
139+ </button>
140+ {/each}
141+ </div>
142+ </section>
143+ {/each}
144+
145+ {#if loadFailed}
146+ <p class="status" role="alert">Could not load the emoji list.</p>
147+ {:else if groups === null}
148+ <p class="status" role="status">Loading emojiโ€ฆ</p>
149+ {:else}
150+ {#each filteredGroups as g (g.name)}
151+ <section class="group" aria-label={g.name}>
152+ <h3>{g.name}</h3>
153+ <div class="grid">
154+ {#each g.emojis as e (e.emoji)}
155+ <button
156+ type="button"
157+ class="cell"
158+ class:chosen={customising && isQuick(e.emoji)}
159+ title={e.label}
160+ aria-label={e.label}
161+ aria-pressed={customising ? isQuick(e.emoji) : undefined}
162+ disabled={customising && full && !isQuick(e.emoji)}
163+ on:click={() => choose(e.emoji, e.shortcodes[0] ?? null)}
164+ >
165+ <Emoji emoji={e.emoji} size={24} />
166+ </button>
167+ {/each}
168+ </div>
169+ </section>
170+ {/each}
171+ {/if}
172+ {#if nothingFound}
173+ <p class="status" role="status">No emoji match "{query.trim()}".</p>
174+ {/if}
175+ </div>
176+
177+ <div class="foot">
178+ {#if customising}
179+ <span class="muted">{full ? 'Quick reactions are full. Remove one to add another.' : 'Select emoji to add or remove them.'}</span>
180+ {/if}
181+ <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
182+ {customising ? 'Done' : 'Customise quick reactions'}
183+ </button>
184+ </div>
185+</div>
186+
187+<style>
188+ .picker {
189+ outline: none;
190+ display: flex; flex-direction: column; gap: 6px;
191+ width: 320px; height: 380px; max-width: 100%; max-height: 100%;
192+ padding: 8px;
193+ background: var(--panel);
194+ border: 1px solid var(--border);
195+ border-radius: 10px;
196+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
197+ }
198+ .search {
199+ background: var(--bg-soft);
200+ border: 1px solid var(--border);
201+ border-radius: 8px;
202+ color: var(--text);
203+ padding: 6px 10px;
204+ font-size: 13px;
205+ }
206+ .search:focus-visible { outline: none; border-color: var(--accent); }
207+ .muted { color: var(--muted); font-size: 12px; }
208+ .quick { border: 1px solid var(--border); border-radius: 8px; padding: 6px; background: var(--bg-soft); }
209+ .quick-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
210+ .quick-row { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
211+ .quick-item {
212+ display: inline-flex; align-items: center; gap: 2px;
213+ padding: 1px 2px 1px 6px;
214+ border: 1px solid var(--border); border-radius: 999px;
215+ font-size: 16px;
216+ }
217+ .quick-item img { width: 18px; height: 18px; object-fit: contain; }
218+ .remove {
219+ background: transparent; border: none; color: var(--muted);
220+ cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: 999px;
221+ }
222+ .remove:hover { color: var(--danger); }
223+ .scroll { flex: 1; min-height: 0; overflow-y: auto; }
224+ .group h3 {
225+ margin: 6px 2px 2px; font-size: 11px; font-weight: 650;
226+ text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
227+ }
228+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 2px; }
229+ .cell {
230+ display: grid; place-items: center;
231+ height: 32px; padding: 0;
232+ background: transparent; border: 1px solid transparent; border-radius: 6px;
233+ font-size: 20px; cursor: pointer; color: var(--text);
234+ }
235+ .cell img { width: 24px; height: 24px; object-fit: contain; }
236+ .cell:hover:not(:disabled), .cell:focus-visible { background: rgba(255, 255, 255, 0.08); border-color: var(--border); outline: none; }
237+ .cell.chosen { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
238+ .cell:disabled { opacity: 0.3; cursor: default; }
239+ .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0; }
240+ .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
241+ .mode {
242+ margin-left: auto;
243+ background: transparent; border: 1px solid var(--border); border-radius: 6px;
244+ color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;
245+ }
246+ .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }
247+</style>
Modified file: web/src/lib/api.ts+3 additions
@@ -36,6 +36,9 @@ export const closeRoom = (room_id: string) => transport.closeRoom(room_id);
3636 export const sendMessage = (room_id: string, body: string) => transport.sendMessage(room_id, body);
3737 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
3838 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
39+export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
40+export const setAccountData = (eventType: string, content: Record<string, unknown>) =>
41+ transport.setAccountData(eventType, content);
3942
4043 export function joinRoom(room: string): Promise<{ room_id: string }> {
4144 return transport.joinRoom(room);
Added file: web/src/lib/emoji.ts+82 additions
@@ -0,0 +1,82 @@
1+export interface EmojiEntry {
2+ emoji: string;
3+ label: string;
4+ tags: string[];
5+ shortcodes: string[];
6+}
7+
8+export interface EmojiGroup {
9+ name: string;
10+ emojis: EmojiEntry[];
11+}
12+
13+interface RawEmoji {
14+ hexcode: string;
15+ unicode: string;
16+ label: string;
17+ group?: number;
18+ order?: number;
19+ tags?: string[];
20+}
21+
22+const GROUP_NAMES: Record<number, string> = {
23+ 0: 'Smileys & emotion',
24+ 1: 'People & body',
25+ 3: 'Animals & nature',
26+ 4: 'Food & drink',
27+ 5: 'Travel & places',
28+ 6: 'Activities',
29+ 7: 'Objects',
30+ 8: 'Symbols',
31+ 9: 'Flags'
32+};
33+
34+let loading: Promise<EmojiGroup[]> | null = null;
35+
36+export function loadEmoji(): Promise<EmojiGroup[]> {
37+ loading ??= (async () => {
38+ const [compact, codes] = await Promise.all([
39+ import('emojibase-data/en/compact.json'),
40+ import('emojibase-data/en/shortcodes/emojibase.json')
41+ ]);
42+ const shortcodes = codes.default as Record<string, string | string[]>;
43+ const byGroup = new Map<number, EmojiEntry[]>();
44+ for (const raw of compact.default as RawEmoji[]) {
45+ if (raw.group === undefined || !(raw.group in GROUP_NAMES)) continue;
46+ const code = shortcodes[raw.hexcode];
47+ const entry: EmojiEntry = {
48+ emoji: raw.unicode,
49+ label: raw.label,
50+ tags: raw.tags ?? [],
51+ shortcodes: code === undefined ? [] : Array.isArray(code) ? code : [code]
52+ };
53+ const list = byGroup.get(raw.group) ?? [];
54+ list.push(entry);
55+ byGroup.set(raw.group, list);
56+ }
57+ return Object.entries(GROUP_NAMES).flatMap(([id, name]) => {
58+ const emojis = byGroup.get(Number(id));
59+ return emojis ? [{ name, emojis }] : [];
60+ });
61+ })().catch((e) => {
62+ loading = null;
63+ throw e;
64+ });
65+ return loading;
66+}
67+
68+export function matchesEmoji(entry: EmojiEntry, query: string): boolean {
69+ const q = query.trim().toLowerCase().replace(/^:|:$/g, '');
70+ if (!q) return true;
71+ return (
72+ entry.label.toLowerCase().includes(q) ||
73+ entry.tags.some((t) => t.toLowerCase().includes(q)) ||
74+ entry.shortcodes.some((s) => s.toLowerCase().includes(q))
75+ );
76+}
77+
78+export function searchEmoji(groups: EmojiGroup[], query: string): EmojiGroup[] {
79+ return groups
80+ .map((g) => ({ name: g.name, emojis: g.emojis.filter((e) => matchesEmoji(e, query)) }))
81+ .filter((g) => g.emojis.length > 0);
82+}
Added file: web/src/lib/quick-reactions.ts+51 additions
@@ -0,0 +1,51 @@
1+import { writable } from 'svelte/store';
2+import { getAccountData, setAccountData } from './api';
3+import { MAX_QUICK_REACTIONS } from './types';
4+import type { QuickReaction } from './types';
5+
6+const EVENT_TYPE = 'dev.malice.quick_reactions';
7+
8+export const DEFAULT_QUICK_REACTIONS: QuickReaction[] = [
9+ { key: '๐Ÿ‘', shortcode: 'thumbsup' },
10+ { key: 'โค๏ธ', shortcode: 'heart' },
11+ { key: '๐Ÿ˜‚', shortcode: 'joy' },
12+ { key: '๐ŸŽ‰', shortcode: 'tada' },
13+ { key: '๐Ÿ˜ฎ', shortcode: 'open_mouth' },
14+ { key: '๐Ÿ˜ข', shortcode: 'cry' }
15+];
16+
17+export const quickReactions = writable<QuickReaction[]>(DEFAULT_QUICK_REACTIONS);
18+
19+function parse(raw: unknown): QuickReaction[] | null {
20+ if (typeof raw !== 'object' || raw === null) return null;
21+ const list = (raw as Record<string, unknown>)['reactions'];
22+ if (!Array.isArray(list)) return null;
23+ const seen = new Set<string>();
24+ const out: QuickReaction[] = [];
25+ for (const item of list) {
26+ if (typeof item !== 'object' || item === null) continue;
27+ const { key, shortcode } = item as Record<string, unknown>;
28+ if (typeof key !== 'string' || !key || seen.has(key)) continue;
29+ seen.add(key);
30+ out.push({ key, shortcode: typeof shortcode === 'string' ? shortcode : null });
31+ }
32+ return out.slice(0, MAX_QUICK_REACTIONS);
33+}
34+
35+export async function loadQuickReactions(): Promise<void> {
36+ try {
37+ quickReactions.set(parse(await getAccountData(EVENT_TYPE)) ?? DEFAULT_QUICK_REACTIONS);
38+ } catch {
39+ quickReactions.set(DEFAULT_QUICK_REACTIONS);
40+ }
41+}
42+
43+export async function saveQuickReactions(list: QuickReaction[]): Promise<void> {
44+ const reactions = list.slice(0, MAX_QUICK_REACTIONS);
45+ await setAccountData(EVENT_TYPE, { reactions });
46+ quickReactions.set(reactions);
47+}
48+
49+export function resetQuickReactions(): void {
50+ quickReactions.set(DEFAULT_QUICK_REACTIONS);
51+}
Modified file: web/src/lib/store.ts+2 additions
@@ -2,6 +2,7 @@ import { writable } from 'svelte/store';
22 import { getSession, subscribeEvents } from './api';
33 import type { SoftLogout } from './api';
44 import { isRecord } from './guards';
5+import { resetQuickReactions } from './quick-reactions';
56 import {
67 parseEncryptionState,
78 parseRoomList,
@@ -56,6 +57,7 @@ export function resetClientState(): void {
5657 encryption.set(null);
5758 verification.set(null);
5859 banners.set([]);
60+ resetQuickReactions();
5961 }
6062
6163 function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
Modified file: web/src/lib/transport-http.ts+17 additions
@@ -140,6 +140,23 @@ export class HttpTransport implements Transport {
140140 return json(res, 'Could not load profile');
141141 }
142142
143+ async getAccountData(eventType: string): Promise<unknown | null> {
144+ const res = await fetch(`/api/account-data?event_type=${encodeURIComponent(eventType)}`, {
145+ credentials: 'same-origin'
146+ });
147+ return json(res, 'Could not load account data');
148+ }
149+
150+ async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
151+ const res = await fetch('/api/account-data', {
152+ method: 'POST',
153+ credentials: 'same-origin',
154+ headers: { 'content-type': 'application/json' },
155+ body: JSON.stringify({ event_type: eventType, content })
156+ });
157+ return json(res, 'Could not save account data');
158+ }
159+
143160 async getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {
144161 const res = await fetch(`/api/spaces/children?room_id=${encodeURIComponent(room_id)}`, {
145162 credentials: 'same-origin'
Modified file: web/src/lib/transport-worker.ts+6 additions
@@ -245,6 +245,12 @@ export class WorkerTransport implements Transport {
245245 getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {
246246 return this.client.call('profile');
247247 }
248+ getAccountData(eventType: string): Promise<unknown | null> {
249+ return this.client.call('get_account_data', { event_type: eventType });
250+ }
251+ setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
252+ return this.client.call('set_account_data', { event_type: eventType, content });
253+ }
248254 getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {
249255 return this.client.call('space_children', { room_id });
250256 }
Modified file: web/src/lib/transport.ts+2 additions
@@ -38,6 +38,8 @@ export interface Transport {
3838 sendMessage(room_id: string, body: string): Promise<void>;
3939 paginateRoom(room_id: string): Promise<void>;
4040 setTyping(room_id: string, typing: boolean): Promise<void>;
41+ getAccountData(eventType: string): Promise<unknown | null>;
42+ setAccountData(eventType: string, content: Record<string, unknown>): Promise<void>;
4143 joinRoom(room: string): Promise<{ room_id: string }>;
4244 createRoom(name: string, topic?: string): Promise<{ room_id: string }>;
4345 leaveRoom(room_id: string): Promise<void>;
Added file: web/src/lib/twemoji.ts+48 additions
@@ -0,0 +1,48 @@
1+const ZWJ = 0x200d;
2+const VS16 = 0xfe0f;
3+
4+export function twemojiUrl(emoji: string): string {
5+ const points = Array.from(emoji, (c) => c.codePointAt(0) ?? 0);
6+ const kept = points.includes(ZWJ) ? points : points.filter((p) => p !== VS16);
7+ return `/twemoji/${kept.map((p) => p.toString(16)).join('-')}.svg`;
8+}
9+
10+export interface TextSegment {
11+ text: string;
12+ emoji: boolean;
13+}
14+
15+const MAYBE_EMOJI = /\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20E3/u;
16+const PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
17+const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u;
18+const KEYCAP = /^[0-9#*]\uFE0F\u20E3$/;
19+const FLAG = /^\p{Regional_Indicator}{2}$/u;
20+
21+let segmenter: Intl.Segmenter | null = null;
22+
23+function isEmojiGrapheme(g: string): boolean {
24+ if (KEYCAP.test(g) || FLAG.test(g)) return true;
25+ return PICTOGRAPHIC.test(g) && (EMOJI_PRESENTATION.test(g) || g.includes(String.fromCodePoint(VS16)) || g.includes(String.fromCodePoint(ZWJ)));
26+}
27+
28+export function segmentEmoji(text: string): TextSegment[] {
29+ if (!MAYBE_EMOJI.test(text)) return [{ text, emoji: false }];
30+ segmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
31+ const out: TextSegment[] = [];
32+ for (const { segment } of segmenter.segment(text)) {
33+ const emoji = isEmojiGrapheme(segment);
34+ const last = out[out.length - 1];
35+ if (last && !emoji && !last.emoji) last.text += segment;
36+ else out.push({ text: segment, emoji });
37+ }
38+ return out;
39+}
40+
41+export function isJumbo(segments: TextSegment[]): boolean {
42+ let count = 0;
43+ for (const s of segments) {
44+ if (s.emoji) count += 1;
45+ else if (s.text.trim() !== '') return false;
46+ }
47+ return count >= 1 && count <= 3;
48+}
Modified file: web/src/lib/types.ts+20 additions
@@ -647,3 +647,23 @@ export function parseTimelineList(raw: unknown): TimelineItem[] {
647647 }
648648 return out;
649649 }
650+
651+export type PackUsage = 'emoticon' | 'sticker';
652+export interface PackImage {
653+ shortcode: string;
654+ url: string;
655+ body: string | null;
656+ usage: PackUsage[];
657+}
658+export interface ImagePack {
659+ id: string;
660+ display_name: string;
661+ avatar_url: string | null;
662+ source: { kind: 'User' } | { kind: 'Room'; room_id: string; state_key: string };
663+ images: PackImage[];
664+}
665+export const MAX_QUICK_REACTIONS = 8;
666+export interface QuickReaction {
667+ key: string;
668+ shortcode: string | null;
669+}
Modified file: web/src/sw.ts+1 additionsโˆ’1 deletions
@@ -467,7 +467,7 @@ sw.addEventListener('fetch', (event) => {
467467 return;
468468 }
469469 }
470- if (url.pathname.startsWith('/assets/')) {
470+ if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/twemoji/')) {
471471 event.respondWith(cacheFirst(event.request));
472472 return;
473473 }
Added file: web/test/twemoji.test.ts+54 additions
@@ -0,0 +1,54 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { isJumbo, segmentEmoji, twemojiUrl } from '../src/lib/twemoji.ts';
4+
5+const emojiOf = (text: string) => segmentEmoji(text).filter((s) => s.emoji).map((s) => s.text);
6+
7+test('plain text takes the fast path', () => {
8+ assert.deepEqual(segmentEmoji('hello world 123 #1 *'), [{ text: 'hello world 123 #1 *', emoji: false }]);
9+});
10+
11+test('splits text and emoji', () => {
12+ assert.deepEqual(segmentEmoji('hi ๐ŸŽ‰ there'), [
13+ { text: 'hi ', emoji: false },
14+ { text: '๐ŸŽ‰', emoji: true },
15+ { text: ' there', emoji: false }
16+ ]);
17+});
18+
19+test('keeps ZWJ sequences whole', () => {
20+ assert.deepEqual(emojiOf('a ๐Ÿ‘ฉโ€๐Ÿ’ป b ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ'), ['๐Ÿ‘ฉโ€๐Ÿ’ป', '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ']);
21+});
22+
23+test('keeps skin tones whole', () => {
24+ assert.deepEqual(emojiOf('๐Ÿ‘๐Ÿฝ'), ['๐Ÿ‘๐Ÿฝ']);
25+});
26+
27+test('treats a regional indicator pair as one flag', () => {
28+ assert.deepEqual(emojiOf('๐Ÿ‡ณ๐Ÿ‡ฟ and ๐Ÿ‡ฏ๐Ÿ‡ต'), ['๐Ÿ‡ณ๐Ÿ‡ฟ', '๐Ÿ‡ฏ๐Ÿ‡ต']);
29+});
30+
31+test('keycaps need FE0F and 20E3', () => {
32+ assert.deepEqual(emojiOf('1๏ธโƒฃ #๏ธโƒฃ'), ['1๏ธโƒฃ', '#๏ธโƒฃ']);
33+ assert.deepEqual(emojiOf('1 # *'), []);
34+});
35+
36+test('text presentation symbols stay text without FE0F', () => {
37+ assert.deepEqual(emojiOf('ยฉ ยฎ โ˜บ'), []);
38+ assert.deepEqual(emojiOf('โ˜บ๏ธ'), ['โ˜บ๏ธ']);
39+});
40+
41+test('jumbo only for one to three emoji', () => {
42+ assert.equal(isJumbo(segmentEmoji('๐ŸŽ‰')), true);
43+ assert.equal(isJumbo(segmentEmoji('๐ŸŽ‰ ๐ŸŽ‰๐ŸŽ‰')), true);
44+ assert.equal(isJumbo(segmentEmoji('๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰๐ŸŽ‰')), false);
45+ assert.equal(isJumbo(segmentEmoji('hi ๐ŸŽ‰')), false);
46+ assert.equal(isJumbo(segmentEmoji('hello')), false);
47+});
48+
49+test('twemojiUrl follows the filename rule', () => {
50+ assert.equal(twemojiUrl('๐ŸŽ‰'), '/twemoji/1f389.svg');
51+ assert.equal(twemojiUrl('โค๏ธ'), '/twemoji/2764.svg');
52+ assert.equal(twemojiUrl('๐Ÿ‘จโ€โš•๏ธ'), '/twemoji/1f468-200d-2695-fe0f.svg');
53+ assert.equal(twemojiUrl('1๏ธโƒฃ'), '/twemoji/31-20e3.svg');
54+});
Modified file: web/tsconfig.json+1 additionsโˆ’1 deletions
@@ -7,7 +7,7 @@
77 "skipLibCheck": true,
88 "isolatedModules": true,
99 "resolveJsonModule": true,
10- "types": ["vite/client"]
10+ "types": ["vite/client", "node"]
1111 },
1212 "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
1313 "exclude": ["src/worker/**", "src/sw.ts"]
Modified file: web/vite.config.ts+33 additionsโˆ’2 deletions
@@ -1,3 +1,6 @@
1+import { readdirSync, readFileSync } from 'node:fs';
2+import { join } from 'node:path';
3+import { fileURLToPath } from 'node:url';
14 import { defineConfig } from 'vite';
25 import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
36
@@ -13,7 +16,9 @@ function precacheManifestPlugin(): import('vite').Plugin {
1316 name: 'malice-sw-precache-manifest',
1417 generateBundle(_options, bundle) {
1518 const urls = Object.keys(bundle)
16- .filter((fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json')
19+ .filter(
20+ (fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json' && !fileName.startsWith('twemoji/')
21+ )
1722 .map((fileName) => `/${fileName}`);
1823 const swChunk = bundle['sw.js'];
1924 if (swChunk && swChunk.type === 'chunk') {
@@ -23,10 +28,36 @@ function precacheManifestPlugin(): import('vite').Plugin {
2328 };
2429 }
2530
31+function twemojiPlugin(): import('vite').Plugin {
32+ const dir = fileURLToPath(new URL('./node_modules/@twemoji/svg', import.meta.url));
33+ const svgs = () => readdirSync(dir).filter((f) => f.endsWith('.svg'));
34+ return {
35+ name: 'malice-twemoji',
36+ configureServer(server) {
37+ server.middlewares.use('/twemoji', (req, res, next) => {
38+ const name = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\//, '');
39+ if (!/^[0-9a-f-]+\.svg$/.test(name)) return next();
40+ try {
41+ res.setHeader('Content-Type', 'image/svg+xml');
42+ res.end(readFileSync(join(dir, name)));
43+ } catch {
44+ res.statusCode = 404;
45+ res.end();
46+ }
47+ });
48+ },
49+ generateBundle() {
50+ for (const file of svgs()) {
51+ this.emitFile({ type: 'asset', fileName: `twemoji/${file}`, source: readFileSync(join(dir, file)) });
52+ }
53+ }
54+ };
55+}
56+
2657 export default defineConfig(({ mode }) => {
2758 const device = mode === 'device';
2859 return {
29- plugins: [svelte({ preprocess: vitePreprocess() }), ...(device ? [precacheManifestPlugin()] : [])],
60+ plugins: [svelte({ preprocess: vitePreprocess() }), twemojiPlugin(), ...(device ? [precacheManifestPlugin()] : [])],
3061 assetsInclude: ['**/*.wasm'],
3162 resolve: {
3263 alias: {