Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ the application.
| `POST` | `/fmsg/:id/send` | Send a message |
| `POST` | `/fmsg/:id/read` | Mark a message as read |
| `POST` | `/fmsg/:id/add-to` | Add recipients |
| `POST` | `/fmsg/:id/react` | Set or clear an emoji reaction (FMSG-005) |
| `GET` | `/fmsg/:id/data` | Download message data |
| `GET` | `/fmsg/:id/thread` | Render direct ancestry as plain text |
| `GET` | `/fmsg/:id/thread/messages` | Load direct ancestry as structured JSON |
Expand Down Expand Up @@ -424,7 +425,10 @@ event types can be added without breaking clients:

| `type` | `data` | Sent when |
| ---------- | ------ | --------- |
| `new_msg` | A message object, same shape as an item in the `GET /fmsg` list response (includes `id`). | A new message arrives for the authenticated user. |
| `new_msg` | A message object, same shape as an item in the `GET /fmsg` list response (includes `id`). | A new message arrives for the authenticated user. Not sent for reactions. |
| `delivered` | The refreshed message object. | A recipient's delivery state changed on a message the user sent. |
| `recipients_added` | The refreshed message object. | An add-to batch was recorded on a message the user participates in. |
| `reaction` | The refreshed **subject** message object, with its `reactions` up to date. | A reaction (FMSG-005) on a message the user participates in arrives. The reaction message itself is not pushed as `new_msg` and triggers no Web Push. |

A client only ever receives events for messages it is a participant on. The
server sends periodic WebSocket pings; clients should respond with pongs (most
Expand Down Expand Up @@ -493,6 +497,7 @@ Add-to recipients are not part of this body — they are added later via `POST /
| `size` | `int` | yes | Data size in bytes |
| `important` | `bool` | no | Mark message as important |
| `no_reply` | `bool` | no | Indicate replies will be discarded |
| `terminal` | `bool` | no | Mark the message terminal: a leaf nobody can reply to or add recipients to (fmsg Specification v0.6.0 flag bit 6, enforced by every host) |
| `data` | `string` | no | Message body content |

**Response:** `201 Created` with `{"id": <int>}`.
Expand All @@ -501,8 +506,9 @@ Add-to recipients are not part of this body — they are added later via `POST /

| Status | Condition |
| ------ | --------- |
| `400` | Missing/invalid fields, empty `to`, or `topic` set together with `pid` |
| `400` | Missing/invalid fields, empty `to`, `topic` set together with `pid`, or `pid` not found |
| `403` | `from` does not match authenticated user |
| `409` | `pid` references a terminal message, which cannot be replied to |

### GET `/fmsg/:id`

Expand All @@ -519,6 +525,7 @@ Retrieves a single message by ID. The authenticated identity must be a participa
"important": false,
"no_reply": false,
"deflate": false,
"terminal": false,
"pid": null,
"from": "@alice@example.com",
"to": ["@bob@example.com"],
Expand All @@ -544,10 +551,28 @@ Retrieves a single message by ID. The authenticated identity must be a participa
"short_text": "hello world",
"read": false,
"time_read": null,
"attachments": []
"attachments": [],
"reaction": null,
"reactions": [
{ "emoji": "👍", "from": ["@bob@example.com", "@carol@example.com"] }
]
}
```

`terminal` is the fmsg _terminal_ flag: the message is a leaf of its thread
and no host will accept a reply to it or an add-to on it.

`reaction` and `reactions` implement
[FMSG-005 Reactions](https://github.com/markmnl/fmsg/blob/main/standards/fmsg-005-reactions.md).
A reaction is an ordinary message recognised by its shape — a reply with
`no_reply` and `terminal` set, type `text/plain;charset=UTF-8`, no attachments,
and a body that is a single emoji (or empty to clear). `reaction` is the emoji
such a message carries (`""` for a clearing reaction) and `null` for every
other message, so clients can hide reaction messages from message lists.
`reactions` lists the effective reactions on this message: each participant's
latest reaction, grouped by emoji in order of first reaction, with the
reactors of each. It is `[]` when there are none.

`add_to` is an array of add-to batches, one per `POST /fmsg/:id/add-to` call.
Each batch has a stable `batch_id` (unique within the database and referenced
with the message ID), records who added the recipients (`add_to_from`), the
Expand Down Expand Up @@ -590,9 +615,10 @@ Updates a draft message. Only the owner (`from`) may update, and the message mus

| Status | Condition |
| ------ | --------- |
| `400` | Invalid fields, or `topic` set together with `pid` |
| `400` | Invalid fields, `topic` set together with `pid`, or `pid` not found |
| `403` | Not the owner, or message already sent |
| `404` | Message not found |
| `409` | `pid` references a terminal message, which cannot be replied to |

### DELETE `/fmsg/:id`

Expand Down Expand Up @@ -680,6 +706,41 @@ New addresses must be distinct among themselves (case-insensitive).
| `400` | Empty `add_to` or duplicate addresses |
| `403` | Authenticated user is not an existing participant (sender or `to` recipient) |
| `404` | Message not found |
| `409` | Message is terminal; recipients cannot be added to it |

### POST `/fmsg/:id/react`

Sets or clears the authenticated identity's reaction on a message, per
[FMSG-005 Reactions](https://github.com/markmnl/fmsg/blob/main/standards/fmsg-005-reactions.md).
The identity must be a participant of the message — the sender or a recipient
in `to` or `add_to` — and the message must be sent and not terminal.

The reaction is sent immediately as a reaction message: a reply to `:id` with
`no_reply` and `terminal` set, type `text/plain;charset=UTF-8`, body the emoji,
addressed to every other participant of the message. Delivery then proceeds
exactly as for any sent message. A reaction message cannot be replied to,
reacted to, or have recipients added.

**Request body (JSON):**

| Field | Type | Required | Description |
| ------- | -------- | -------- | ----------- |
| `emoji` | `string` | no | A single emoji (a Unicode `RGI_Emoji` sequence, or any single well-formed emoji sequence). `""` or `null` clears the caller's reaction. |

Setting the same reaction the caller already has is idempotent: nothing is
sent and the existing reaction message is returned with `200 OK`. Clearing when
the caller has no reaction returns `200 OK` with `null` values.

**Response:** `201 Created` with the reaction message's `{"id": <int>, "time": <number>}`.

**Errors:**

| Status | Condition |
| ------ | --------- |
| `400` | `emoji` is not a single emoji |
| `403` | Authenticated identity is not a participant |
| `404` | Message not found |
| `409` | Message is a draft, is terminal, has no other participants, or a remote recipient host cannot accept the reaction because it does not hold the message |

### GET `/fmsg/:id/data`

Expand Down
1 change: 1 addition & 0 deletions cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func main() {
fmsg.POST("/:id/send", msgHandler.Send)
fmsg.POST("/:id/read", msgHandler.MarkRead)
fmsg.POST("/:id/add-to", msgHandler.AddRecipients)
fmsg.POST("/:id/react", msgHandler.React)
fmsg.GET("/:id/data", msgHandler.DownloadData)
fmsg.GET("/:id/thread", msgHandler.ThreadText)
fmsg.GET("/:id/thread/messages", msgHandler.ThreadMessages)
Expand Down
124 changes: 124 additions & 0 deletions internal/emoji/emoji.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Package emoji recognises single emoji for FMSG-005 reactions.
//
// A reaction's data must be exactly one emoji. [IsRGI] tests membership of the
// Unicode RGI_Emoji set (UTS #51), the set senders are required to use.
// [IsPossible] accepts the wider UTS #51 possible_emoji grammar, restricted to
// sequences that present as emoji, so a reaction using a sequence newer than
// the compiled tables, or a minimally-qualified one, is still recognised as a
// reaction rather than demoted to an ordinary message.
//
// Run `go run ./internal/emoji/gen` from the repository root to refresh the
// tables from unicode.org.
package emoji

import (
"unicode"
"unicode/utf8"
)

const (
zwj = 0x200D
vs16 = 0xFE0F
keycap = 0x20E3
tagBegin = 0xE0020
tagEnd = 0xE007E
tagTerm = 0xE007F
riFirst = 0x1F1E6
riLast = 0x1F1FF
blackFlag = 0x1F3F4
maxReaction = 64 // bytes; FMSG-005 size bound
maxSequenceR = 24 // runes; generous bound for ZWJ sequences
)

// IsRGI reports whether s is exactly one fully-qualified RGI emoji sequence.
func IsRGI(s string) bool {
_, ok := rgi[s]
return ok
}

// IsPossible reports whether s is exactly one emoji under the UTS #51
// possible_emoji grammar, requiring emoji presentation: a text-default
// character such as a digit counts only with U+FE0F, a keycap or a modifier.
//
// possible_emoji := flag_sequence | zwj_element (ZWJ zwj_element)*
// flag_sequence := RI RI
// zwj_element := Emoji emoji_modification?
// emoji_modification := Emoji_Modifier | FE0F 20E3? | tag_modifier
// tag_modifier := [E0020-E007E]+ E007F
func IsPossible(s string) bool {
if s == "" || len(s) > maxReaction || !utf8.ValidString(s) {
return false
}
runes := []rune(s)
if len(runes) > maxSequenceR {
return false
}
if len(runes) == 2 && isRI(runes[0]) && isRI(runes[1]) {
return true
}
i := 0
for {
n, ok := zwjElement(runes[i:])
if !ok {
return false
}
i += n
if i == len(runes) {
return true
}
if runes[i] != zwj {
return false
}
i++
if i == len(runes) {
return false
}
}
}

// IsSingle reports whether s is one emoji by either test.
func IsSingle(s string) bool {
return IsRGI(s) || IsPossible(s)
}

func isRI(r rune) bool { return r >= riFirst && r <= riLast }

// zwjElement consumes one zwj_element from the front of runes, returning its
// length. The element must present as emoji.
func zwjElement(runes []rune) (int, bool) {
if len(runes) == 0 || !unicode.Is(propEmoji, runes[0]) || isRI(runes[0]) {
return 0, false
}
base := runes[0]
presents := unicode.Is(propEmojiPresentation, base)
i := 1
if i < len(runes) {
switch r := runes[i]; {
case unicode.Is(propEmojiModifier, r):
if !unicode.Is(propEmojiModifierBase, base) {
return 0, false
}
i++
presents = true
case r == vs16:
i++
presents = true
if i < len(runes) && runes[i] == keycap {
i++
}
case r >= tagBegin && r <= tagEnd:
if base != blackFlag {
return 0, false
}
for i < len(runes) && runes[i] >= tagBegin && runes[i] <= tagEnd {
i++
}
if i >= len(runes) || runes[i] != tagTerm {
return 0, false
}
i++
presents = true
}
}
return i, presents
}
78 changes: 78 additions & 0 deletions internal/emoji/emoji_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package emoji

import "testing"

func TestIsRGI(t *testing.T) {
for _, s := range []string{
"👍",
"❤️",
"👍🏽",
"🇳🇿",
"👨‍👩‍👧",
"#️⃣",
"🏴󠁧󠁢󠁳󠁣󠁴󠁿",
} {
if !IsRGI(s) {
t.Errorf("IsRGI(%q) = false, want true", s)
}
}
for _, s := range []string{
"",
"a",
"👍👍",
"👍 ",
"❤", // unqualified: U+2764 without VS16
"1",
} {
if IsRGI(s) {
t.Errorf("IsRGI(%q) = true, want false", s)
}
}
}

func TestIsPossible(t *testing.T) {
for _, s := range []string{
"👍",
"❤️",
"👍🏽",
"🇳🇿",
"👨‍👩‍👧",
"#️⃣",
"🏴󠁧󠁢󠁳󠁣󠁴󠁿",
"🧑‍🦯", // any well-formed ZWJ sequence
} {
if !IsPossible(s) {
t.Errorf("IsPossible(%q) = false, want true", s)
}
}
for _, s := range []string{
"",
"a",
"1", // text-default without VS16
"❤", // text-default without VS16
"👍👍", // two emoji
"👍 ", // trailing space
"👍‍", // dangling ZWJ
"‍👍", // leading ZWJ
"🇳", // lone regional indicator
"🇳🇿🇳", // three regional indicators
"a🏽", // modifier on a non-emoji
"👍🏽🏽", // double modifier
"🏳\U000E007F", // tag on a non-black-flag base
"\xff", // invalid UTF-8
} {
if IsPossible(s) {
t.Errorf("IsPossible(%q) = true, want false", s)
}
}
}

func TestIsSingleRejectsOversize(t *testing.T) {
s := ""
for len(s) <= maxReaction {
s += "‍👍"
}
if IsSingle(s) {
t.Errorf("IsSingle accepted %d-byte sequence", len(s))
}
}
Loading
Loading