diff --git a/playwright/e2e/messages/messages.spec.ts b/playwright/e2e/messages/messages.spec.ts new file mode 100644 index 0000000000..bd56d772e8 --- /dev/null +++ b/playwright/e2e/messages/messages.spec.ts @@ -0,0 +1,113 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { Locator, Page } from "playwright-core"; + +import { test, expect } from "../../element-web-test"; + +async function sendMessage(page: Page, message: string): Promise { + await page.getByRole("textbox", { name: "Send a message…" }).fill(message); + await page.getByRole("button", { name: "Send message" }).click(); + + const msgTile = await page.locator(".mx_EventTile_last"); + await msgTile.locator(".mx_EventTile_receiptSent").waitFor(); + return msgTile; +} + +async function editMessage(page: Page, message: Locator, newMsg: string): Promise { + const line = message.locator(".mx_EventTile_line"); + await line.hover(); + await line.getByRole("button", { name: "Edit" }).click(); + const editComposer = page.getByRole("textbox", { name: "Edit message" }); + await page.getByLabel("User menu").hover(); // Just to un-hover the message line + await editComposer.fill(newMsg); + await editComposer.press("Enter"); +} + +test.describe("Message rendering", () => { + [ + { direction: "ltr", displayName: "Quentin" }, + { direction: "rtl", displayName: "كوينتين" }, + ].forEach(({ direction, displayName }) => { + test.describe(`with ${direction} display name`, () => { + test.use({ + displayName, + room: async ({ user, app }, use) => { + const roomId = await app.client.createRoom({ name: "Test room" }); + await use({ roomId }); + }, + }); + + test("should render a basic LTR text message", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "Hello, world!"); + await expect(msgTile).toMatchScreenshot(`basic-message-ltr-${direction}displayname.png`, { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + + test("should render an LTR emote", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "/me lays an egg"); + await expect(msgTile).toMatchScreenshot(`emote-ltr-${direction}displayname.png`); + }); + + test("should render an edited LTR message", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "Hello, world!"); + + await editMessage(page, msgTile, "Hello, universe!"); + + await expect(msgTile).toMatchScreenshot(`edited-message-ltr-${direction}displayname.png`, { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + + test("should render a basic RTL text message", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "مرحبا بالعالم!"); + await expect(msgTile).toMatchScreenshot(`basic-message-rtl-${direction}displayname.png`, { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + + test("should render an RTL emote", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "/me يضع بيضة"); + await expect(msgTile).toMatchScreenshot(`emote-rtl-${direction}displayname.png`); + }); + + test("should render an edited RTL message", async ({ page, user, app, room }) => { + await page.goto(`#/room/${room.roomId}`); + + const msgTile = await sendMessage(page, "مرحبا بالعالم!"); + + await editMessage(page, msgTile, "مرحبا بالكون!"); + + await expect(msgTile).toMatchScreenshot(`edited-message-rtl-${direction}displayname.png`, { + mask: [page.locator(".mx_MessageTimestamp")], + }); + }); + }); + }); +}); diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png new file mode 100644 index 0000000000..fe92443694 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png new file mode 100644 index 0000000000..a0a5dbb8b0 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png new file mode 100644 index 0000000000..cf2da6f023 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png new file mode 100644 index 0000000000..e9aded5a5f Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png new file mode 100644 index 0000000000..1e29c40c73 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png new file mode 100644 index 0000000000..104b8f469e Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png new file mode 100644 index 0000000000..f15894f2b3 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png new file mode 100644 index 0000000000..bec538f32d Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png new file mode 100644 index 0000000000..772bbbbeec Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png new file mode 100644 index 0000000000..04f4e0d1f5 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png new file mode 100644 index 0000000000..ee9d8b8a43 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png differ diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png new file mode 100644 index 0000000000..19075ea869 Binary files /dev/null and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png differ diff --git a/res/css/views/messages/_MEmoteBody.pcss b/res/css/views/messages/_MEmoteBody.pcss index cf722e5ae8..18940844f7 100644 --- a/res/css/views/messages/_MEmoteBody.pcss +++ b/res/css/views/messages/_MEmoteBody.pcss @@ -16,6 +16,7 @@ limitations under the License. .mx_MEmoteBody { white-space: pre-wrap; + text-align: start; } .mx_MEmoteBody_sender { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 66c60f5f15..6786d94511 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -43,6 +43,7 @@ $left-gutter: 64px; .mx_EventTile_body { overflow-y: hidden; + text-align: start; } .mx_EventTile_receiptSent, @@ -676,7 +677,7 @@ $left-gutter: 64px; font-size: $font-12px; color: $secondary-content; display: inline-block; - margin-left: 9px; + margin-inline-start: 9px; } .mx_EventTile_edited { @@ -1443,6 +1444,10 @@ $left-gutter: 64px; } } +.mx_EventTile_annotated { + display: flex; +} + /* Media query for mobile UI */ @media only screen and (max-width: 480px) { .mx_EventTile_content { diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 15b45171fe..774cef87a1 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -303,7 +303,6 @@ export interface EventRenderOpts { disableBigEmoji?: boolean; stripReplyFallback?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; } function analyseEvent(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): EventAnalysis { @@ -375,7 +374,61 @@ function analyseEvent(content: IContent, highlights: Optional, opts: E } } -export function bodyToNode(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): ReactNode { +export function bodyToDiv( + content: IContent, + highlights: Optional, + opts: EventRenderOpts = {}, + ref?: React.Ref, +): ReactNode { + const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts); + + return formattedBody ? ( +
+ ) : ( +
+ {emojiBodyElements || strippedBody} +
+ ); +} + +export function bodyToSpan( + content: IContent, + highlights: Optional, + opts: EventRenderOpts = {}, + ref?: React.Ref, + includeDir = true, +): ReactNode { + const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts); + + return formattedBody ? ( + + ) : ( + + {emojiBodyElements || strippedBody} + + ); +} + +interface BodyToNodeReturn { + strippedBody: string; + formattedBody?: string; + emojiBodyElements: JSX.Element[] | undefined; + className: string; +} + +function bodyToNode(content: IContent, highlights: Optional, opts: EventRenderOpts = {}): BodyToNodeReturn { const eventInfo = analyseEvent(content, highlights, opts); let emojiBody = false; @@ -419,19 +472,7 @@ export function bodyToNode(content: IContent, highlights: Optional, op emojiBodyElements = formatEmojis(eventInfo.strippedBody, false) as JSX.Element[]; } - return formattedBody ? ( - - ) : ( - - {emojiBodyElements || eventInfo.strippedBody} - - ); + return { strippedBody: eventInfo.strippedBody, formattedBody, emojiBodyElements, className }; } /** diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index d688650353..125154c4d9 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -172,7 +172,7 @@ export default class EditHistoryMessage extends React.PureComponent { - private readonly contentRef = createRef(); + private readonly contentRef = createRef(); private unmounted = false; private pills: Element[] = []; @@ -566,34 +566,38 @@ export default class TextualBody extends React.Component { } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); - let isNotice = false; - let isEmote = false; + const isNotice = content.msgtype === MsgType.Notice; + const isEmote = content.msgtype === MsgType.Emote; + + const willHaveWrapper = + this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote; // only strip reply if this is the original replying event, edits thereafter do not have the fallback const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); - isEmote = content.msgtype === MsgType.Emote; - isNotice = content.msgtype === MsgType.Notice; - let body = HtmlUtils.bodyToNode(content, this.props.highlights, { + + const htmlOpts = { disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"), // Part of Replies fallback support stripReplyFallback: stripReply, - ref: this.contentRef, - }); + }; + let body = willHaveWrapper + ? HtmlUtils.bodyToSpan(content, this.props.highlights, htmlOpts, this.contentRef, false) + : HtmlUtils.bodyToDiv(content, this.props.highlights, htmlOpts, this.contentRef); if (this.props.replacingEventId) { body = ( - <> +
{body} {this.renderEditedMarker()} - +
); } if (this.props.isSeeingThroughMessageHiddenForModeration) { body = ( - <> +
{body} {this.renderPendingModerationMarker()} - +
); } @@ -624,7 +628,7 @@ export default class TextualBody extends React.Component { if (isEmote) { return ( -
+
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index b3bad1813c..0de866d452 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -19,7 +19,7 @@ import { mocked } from "jest-mock"; import { render, screen } from "@testing-library/react"; import { IContent } from "matrix-js-sdk/src/matrix"; -import { bodyToNode, formatEmojis, topicToHtml } from "../src/HtmlUtils"; +import { bodyToSpan, formatEmojis, topicToHtml } from "../src/HtmlUtils"; import SettingsStore from "../src/settings/SettingsStore"; jest.mock("../src/settings/SettingsStore"); @@ -66,7 +66,7 @@ describe("topicToHtml", () => { describe("bodyToHtml", () => { function getHtml(content: IContent, highlights?: string[]): string { - return (bodyToNode(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html; + return (bodyToSpan(content, highlights, {}) as ReactElement).props.dangerouslySetInnerHTML.__html; } it("should apply highlights to HTML messages", () => { @@ -108,14 +108,14 @@ describe("bodyToHtml", () => { }); it("generates big emoji for emoji made of multiple characters", () => { - const { asFragment } = render(bodyToNode({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement); + const { asFragment } = render(bodyToSpan({ body: "👨‍👩‍👧‍👦 ↔️ 🇮🇸", msgtype: "m.text" }, [], {}) as ReactElement); expect(asFragment()).toMatchSnapshot(); }); it("should generate big emoji for an emoji-only reply to a message", () => { const { asFragment } = render( - bodyToNode( + bodyToSpan( { "body": "> <@sender1:server> Test\n\n🥰", "format": "org.matrix.custom.html", @@ -139,7 +139,7 @@ describe("bodyToHtml", () => { }); it("does not mistake characters in text presentation mode for emoji", () => { - const { asFragment } = render(bodyToNode({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}) as ReactElement); + const { asFragment } = render(bodyToSpan({ body: "↔ ❗︎", msgtype: "m.text" }, [], {}) as ReactElement); expect(asFragment()).toMatchSnapshot(); }); diff --git a/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index a9934fb32d..56de44137f 100644 --- a/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders formatted m.text correctly italics, bold, underline and strikethrough render as expected 1`] = ` - @@ -21,11 +21,11 @@ exports[` renders formatted m.text correctly italics, bold, under u - +
`; exports[` renders formatted m.text correctly linkification is not applied to code blocks 1`] = ` - @@ -62,11 +62,11 @@ exports[` renders formatted m.text correctly linkification is not
-
+
`; exports[` renders formatted m.text correctly pills appear for an MXID permalink 1`] = ` - @@ -105,7 +105,7 @@ exports[` renders formatted m.text correctly pills appear for an - + `; exports[` renders formatted m.text correctly pills appear for event permalinks without a custom label 1`] = ` @@ -113,7 +113,7 @@ exports[` renders formatted m.text correctly pills appear for eve
- @@ -152,7 +152,7 @@ exports[` renders formatted m.text correctly pills appear for eve - +
`; @@ -162,7 +162,7 @@ exports[` renders formatted m.text correctly pills appear for roo
- @@ -202,7 +202,7 @@ exports[` renders formatted m.text correctly pills appear for roo with vias - +
`; @@ -212,7 +212,7 @@ exports[` renders formatted m.text correctly pills do not appear
- @@ -224,13 +224,13 @@ exports[` renders formatted m.text correctly pills do not appear event link with text - +
`; exports[` renders formatted m.text correctly pills do not appear in code blocks 1`] = ` - @@ -266,11 +266,11 @@ exports[` renders formatted m.text correctly pills do not appear - + `; exports[` renders formatted m.text correctly pills get injected correctly into the DOM 1`] = ` - @@ -309,20 +309,20 @@ exports[` renders formatted m.text correctly pills get injected c - + `; exports[` renders formatted m.text correctly renders formatted body without html correctly 1`] = ` - escaped *markdown* - + `; exports[` renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = ` - @@ -346,29 +346,28 @@ exports[` renders formatted m.text correctly spoilers get injecte - + `; exports[` renders m.emote correctly 1`] = ` winks `; exports[` renders m.notice correctly 1`] = ` - this is a notice, probably from a bot - + `; exports[` renders plain-text m.text correctly linkification get applied correctly into the DOM 1`] = ` - @@ -381,7 +380,7 @@ exports[` renders plain-text m.text correctly linkification get a > https://matrix.org/ - + `; exports[` renders plain-text m.text correctly should pillify a permalink to a message in the same room with the label »Message from Member« 1`] = `"Visit Message from Member"`; @@ -389,7 +388,7 @@ exports[` renders plain-text m.text correctly should pillify a pe exports[` renders plain-text m.text correctly should pillify a permalink to an event in another room with the label »Message in Room 2« 1`] = `"Visit Message in Room 2"`; exports[` renders plain-text m.text correctly should pillify a permalink to an unknown message in the same room with the label »Message« 1`] = ` - @@ -411,14 +410,14 @@ exports[` renders plain-text m.text correctly should pillify a pe - + `; exports[` renders plain-text m.text correctly simple message renders as expected 1`] = ` - this is a plaintext message - + `; diff --git a/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap index 45b1bf2e3a..960177a38e 100644 --- a/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/PinnedEventTile-test.tsx.snap @@ -26,12 +26,12 @@ exports[` should render pinned event 1`] = `
- First pinned message - +
should render 1`] = `
- Hey you. You're the best! - +
should render 1`] = `
- Hey you. You're the best! - +
should render 1`] = `
- Hey you. You're the best! - +
- Hey you. You're the best! - +
- Hey you. You're the best! - +
- Hey you. You're the best! - +

-
  • @user49:example.com
    Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0
  • +
  • @user49:example.com
    Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0