From 5ff965106a27ad54c1d6c4823dba5349b38a9a92 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 16 Oct 2023 15:14:04 +0100 Subject: [PATCH] Render timeline separator for late event groups (#11739) * Use Compound tooltips on MessageTimestamp to improve UX of date time discovery Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Show io.element.late_event in MessageTimestamp when known Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update snapshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Avoid needing new Compound changes Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Move groupers into their own directory Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Refactor date separator code to be more generic Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Render timeline separator for late event groups Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix date used in copy Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update snapshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Move groupers into their own directory Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update copy Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update copy Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update snapshot Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add comments Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add comments Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- cypress/e2e/editing/editing.spec.ts | 4 +- res/css/_components.pcss | 1 + res/css/views/messages/_DateSeparator.pcss | 16 ---- .../views/messages/_TimelineSeparator.pcss | 31 ++++++++ res/css/views/right_panel/_ThreadPanel.pcss | 2 +- src/components/structures/MessagePanel.tsx | 78 ++++++++++++------- .../structures/grouper/CreationGrouper.tsx | 5 +- .../structures/grouper/LateEventGrouper.ts | 43 ++++++++++ .../structures/grouper/MainGrouper.tsx | 5 +- .../views/messages/DateSeparator.tsx | 15 ++-- .../views/messages/TimelineSeparator.tsx | 46 +++++++++++ src/components/views/rooms/EventTile.tsx | 3 +- src/i18n/strings/en_EN.json | 1 + .../__snapshots__/MessagePanel-test.tsx.snap | 2 +- .../MessageEditHistoryDialog-test.tsx.snap | 4 +- .../__snapshots__/DateSeparator-test.tsx.snap | 4 +- .../views/rooms/SearchResultTile-test.tsx | 2 +- .../__snapshots__/HTMLExport-test.ts.snap | 2 +- 18 files changed, 198 insertions(+), 66 deletions(-) create mode 100644 res/css/views/messages/_TimelineSeparator.pcss create mode 100644 src/components/structures/grouper/LateEventGrouper.ts create mode 100644 src/components/views/messages/TimelineSeparator.tsx diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index b7dacf8603..8695531a60 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -117,7 +117,7 @@ describe("Editing", () => { cy.get(".mx_EventTile").should("have.css", "padding-block-start", "0px"); // Assert that the date separator is rendered at the top - cy.get("li:nth-child(1) .mx_DateSeparator").within(() => { + cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => { cy.get("h2").within(() => { cy.findByText("today").should("have.css", "text-transform", "capitalize"); }); @@ -182,7 +182,7 @@ describe("Editing", () => { // Assert that the message edit history dialog is rendered again cy.get(".mx_MessageEditHistoryDialog").within(() => { // Assert that the date is rendered - cy.get("li:nth-child(1) .mx_DateSeparator").within(() => { + cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => { cy.get("h2").within(() => { cy.findByText("today").should("have.css", "text-transform", "capitalize"); }); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 4297b9f190..ff4c10c260 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -251,6 +251,7 @@ @import "./views/messages/_RedactedBody.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; +@import "./views/messages/_TimelineSeparator.pcss"; @import "./views/messages/_UnknownBody.pcss"; @import "./views/messages/_ViewSourceEvent.pcss"; @import "./views/messages/_common_CryptoEvent.pcss"; diff --git a/res/css/views/messages/_DateSeparator.pcss b/res/css/views/messages/_DateSeparator.pcss index 52d263f688..de0cd66832 100644 --- a/res/css/views/messages/_DateSeparator.pcss +++ b/res/css/views/messages/_DateSeparator.pcss @@ -14,22 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DateSeparator { - clear: both; - margin: 4px 0; - display: flex; - align-items: center; - font: var(--cpd-font-body-md-regular); - color: $roomtopic-color; -} - -.mx_DateSeparator > hr { - flex: 1 1 0; - height: 0; - border: none; - border-bottom: 1px solid $menu-selected-color; -} - .mx_DateSeparator_dateContent { padding: 0 25px; } diff --git a/res/css/views/messages/_TimelineSeparator.pcss b/res/css/views/messages/_TimelineSeparator.pcss new file mode 100644 index 0000000000..40ca0967a6 --- /dev/null +++ b/res/css/views/messages/_TimelineSeparator.pcss @@ -0,0 +1,31 @@ +/* +Copyright 2017 Vector Creations Ltd + +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. +*/ + +.mx_TimelineSeparator { + clear: both; + margin: 4px 0; + display: flex; + align-items: center; + font: var(--cpd-font-body-md-regular); + color: $roomtopic-color; +} + +.mx_TimelineSeparator > hr { + flex: 1 1 0; + height: 0; + border: none; + border-bottom: 1px solid $menu-selected-color; +} diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index d772a2d628..9d14c993df 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -112,7 +112,7 @@ limitations under the License. /* Account for scrollbar when hovering */ padding-top: 0; - .mx_DateSeparator { + .mx_TimelineSeparator { display: none; } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 8e7073ebbe..3a97f27b38 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -20,10 +20,9 @@ import classNames from "classnames"; import { Room, MatrixClient, RoomStateEvent, EventStatus, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; -import { Optional } from "matrix-events-sdk"; import shouldHideEvent from "../../shouldHideEvent"; -import { wantsDateSeparator } from "../../DateUtils"; +import { formatDate, wantsDateSeparator } from "../../DateUtils"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import SettingsStore from "../../settings/SettingsStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; @@ -40,6 +39,7 @@ import LegacyCallEventGrouper from "./LegacyCallEventGrouper"; import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; import ScrollPanel, { IScrollState } from "./ScrollPanel"; import DateSeparator from "../views/messages/DateSeparator"; +import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import ResizeNotifier from "../../utils/ResizeNotifier"; import Spinner from "../views/elements/Spinner"; @@ -54,6 +54,8 @@ import { hasThreadSummary } from "../../utils/EventUtils"; import { BaseGrouper } from "./grouper/BaseGrouper"; import { MainGrouper } from "./grouper/MainGrouper"; import { CreationGrouper } from "./grouper/CreationGrouper"; +import { _t } from "../../languageHandler"; +import { getLateEventInfo } from "./grouper/LateEventGrouper"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -739,31 +741,38 @@ export default class MessagePanel extends React.Component { const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId(); // local echoes have a fake date, which could even be yesterday. Treat them as 'today' for the date separators. - let ts1 = mxEv.getTs(); - let eventDate = mxEv.getDate(); - if (mxEv.status) { - eventDate = new Date(); - ts1 = eventDate.getTime(); - } + const ts1 = mxEv.getTs() ?? Date.now(); - // do we need a date separator since the last event? - const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator && !isGrouped && this.props.room) { - const dateSeparator = ( -
  • - -
  • - ); - ret.push(dateSeparator); + // do we need a separator since the last event? + const wantsSeparator = this.wantsSeparator(prevEvent, mxEv); + if (!isGrouped && this.props.room) { + if (wantsSeparator === SeparatorKind.Date) { + ret.push( +
  • + +
  • , + ); + } else if (wantsSeparator === SeparatorKind.LateEvent) { + const text = _t("timeline|late_event_separator", { + dateTime: formatDate(mxEv.getDate() ?? new Date()), + }); + ret.push( +
  • + + {text} + +
  • , + ); + } } const cli = MatrixClientPeg.safeGet(); let lastInSection = true; if (nextEventWithTile) { const nextEv = nextEventWithTile; - const willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEv.getDate() || new Date()); + const willWantSeparator = this.wantsSeparator(mxEv, nextEv); lastInSection = - willWantDateSeparator || + willWantSeparator === SeparatorKind.Date || mxEv.getSender() !== nextEv.getSender() || getEventDisplayInfo(cli, nextEv, this.showHiddenEvents).isInfoMessage || !shouldFormContinuation(mxEv, nextEv, cli, this.showHiddenEvents, this.context.timelineRenderingType); @@ -771,7 +780,7 @@ export default class MessagePanel extends React.Component { // is this a continuation of the previous message? const continuation = - !wantsDateSeparator && + wantsSeparator === SeparatorKind.None && shouldFormContinuation(prevEvent, mxEv, cli, this.showHiddenEvents, this.context.timelineRenderingType); const eventId = mxEv.getId()!; @@ -816,16 +825,31 @@ export default class MessagePanel extends React.Component { return ret; } - public wantsDateSeparator(prevEvent: MatrixEvent | null, nextEventDate: Optional): boolean { + public wantsSeparator(prevEvent: MatrixEvent | null, mxEvent: MatrixEvent): SeparatorKind { if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) { - return false; + return SeparatorKind.None; } - if (prevEvent == null) { - // first event in the panel: depends if we could back-paginate from - // here. - return !this.props.canBackPaginate; + + if (prevEvent !== null) { + // If the previous event was late but current is not then show a date separator for orientation + // Otherwise if the current event is of a different late group than the previous show a late event separator + const lateEventInfo = getLateEventInfo(mxEvent); + if (lateEventInfo?.group_id !== getLateEventInfo(prevEvent)?.group_id) { + return lateEventInfo !== undefined ? SeparatorKind.LateEvent : SeparatorKind.Date; + } } - return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate); + + // first event in the panel: depends on if we could back-paginate from here. + if (prevEvent === null && !this.props.canBackPaginate) { + return SeparatorKind.Date; + } + + const nextEventDate = mxEvent.getDate() ?? new Date(); + if (prevEvent !== null && wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate)) { + return SeparatorKind.Date; + } + + return SeparatorKind.None; } // Get a list of read receipts that should be shown next to this event diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx index b557188d15..0ceb6f5857 100644 --- a/src/components/structures/grouper/CreationGrouper.tsx +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler"; import DateSeparator from "../../views/messages/DateSeparator"; import NewRoomIntro from "../../views/rooms/NewRoomIntro"; import GenericEventListSummary from "../../views/elements/GenericEventListSummary"; +import { SeparatorKind } from "../../views/messages/TimelineSeparator"; // Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until @@ -41,7 +42,7 @@ export class CreationGrouper extends BaseGrouper { if (!shouldShow) { return true; } - if (panel.wantsDateSeparator(this.firstEventAndShouldShow.event, event.getDate())) { + if (panel.wantsSeparator(this.firstEventAndShouldShow.event, event) === SeparatorKind.Date) { return false; } const eventType = event.getType(); @@ -96,7 +97,7 @@ export class CreationGrouper extends BaseGrouper { const createEvent = this.firstEventAndShouldShow; const lastShownEvent = this.lastShownEvent; - if (panel.wantsDateSeparator(this.prevEvent, createEvent.event.getDate())) { + if (panel.wantsSeparator(this.prevEvent, createEvent.event) === SeparatorKind.Date) { const ts = createEvent.event.getTs(); ret.push(
  • diff --git a/src/components/structures/grouper/LateEventGrouper.ts b/src/components/structures/grouper/LateEventGrouper.ts new file mode 100644 index 0000000000..5e7f0d19de --- /dev/null +++ b/src/components/structures/grouper/LateEventGrouper.ts @@ -0,0 +1,43 @@ +/* +Copyright 2023 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. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +const UNSIGNED_KEY = "io.element.late_event"; + +/** + * This metadata describes when events arrive late after a net-split to offer improved UX. + */ +interface UnsignedLateEventInfo { + /** + * Milliseconds since epoch representing the time the event was received by the server + */ + received_at: number; + /** + * An opaque identifier representing the group the server has put the late arriving event into + */ + group_id: string; +} + +/** + * Get io.element.late_event metadata from unsigned as sent by the server. + * + * @experimental this is not in the Matrix spec and needs special server support + * @param mxEvent the Matrix Event to get UnsignedLateEventInfo on + */ +export function getLateEventInfo(mxEvent: MatrixEvent): UnsignedLateEventInfo | undefined { + return mxEvent.getUnsigned()[UNSIGNED_KEY]; +} diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index d6e8f33f2b..28a62d7ac9 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -25,6 +25,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import DateSeparator from "../../views/messages/DateSeparator"; import HistoryTile from "../../views/rooms/HistoryTile"; import EventListSummary from "../../views/elements/EventListSummary"; +import { SeparatorKind } from "../../views/messages/TimelineSeparator"; const groupedStateEvents = [ EventType.RoomMember, @@ -70,7 +71,7 @@ export class MainGrouper extends BaseGrouper { // absorb hidden events so that they do not break up streams of messages & redaction events being grouped return true; } - if (this.panel.wantsDateSeparator(this.events[0].event, ev.getDate())) { + if (this.panel.wantsSeparator(this.events[0].event, ev) === SeparatorKind.Date) { return false; } if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { @@ -114,7 +115,7 @@ export class MainGrouper extends BaseGrouper { const lastShownEvent = this.lastShownEvent; const ret: ReactNode[] = []; - if (panel.wantsDateSeparator(this.prevEvent, this.events[0].event.getDate())) { + if (panel.wantsSeparator(this.prevEvent, this.events[0].event) === SeparatorKind.Date) { const ts = this.events[0].event.getTs(); ret.push(
  • diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 1da5c91c2d..09e78708d6 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -40,6 +40,7 @@ import IconizedContextMenu, { import JumpToDatePicker from "./JumpToDatePicker"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import TimelineSeparator from "./TimelineSeparator"; interface IProps { roomId: string; @@ -52,6 +53,11 @@ interface IState { jumpToDateEnabled: boolean; } +/** + * Timeline separator component to render within a MessagePanel bearing the date of the ts given + * + * Has additional jump to date functionality when labs flag is enabled + */ export default class DateSeparator extends React.Component { private settingWatcherRef?: string; @@ -328,13 +334,6 @@ export default class DateSeparator extends React.Component { ); } - // ARIA treats
    s as separators, here we abuse them slightly so manually treat this entire thing as one - return ( -
    -
    - {dateHeaderContent} -
    -
    - ); + return {dateHeaderContent}; } } diff --git a/src/components/views/messages/TimelineSeparator.tsx b/src/components/views/messages/TimelineSeparator.tsx new file mode 100644 index 0000000000..78e0d1fd65 --- /dev/null +++ b/src/components/views/messages/TimelineSeparator.tsx @@ -0,0 +1,46 @@ +/* +Copyright 2023 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. +*/ + +import React from "react"; + +interface Props { + label: string; +} + +export const enum SeparatorKind { + None, + Date, + LateEvent, +} + +/** + * Generic timeline separator component to render within a MessagePanel + * + * @param label the accessible label string describing the separator + * @param children the children to draw within the timeline separator + */ +const TimelineSeparator: React.FC = ({ label, children }) => { + // ARIA treats
    s as separators, here we abuse them slightly so manually treat this entire thing as one + return ( +
    +
    + {children} +
    +
    + ); +}; + +export default TimelineSeparator; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index f5166f0c07..745ec3668e 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -85,6 +85,7 @@ import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { ElementCall } from "../../../models/Call"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; +import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; export type GetRelationsForEvent = ( eventId: string, @@ -1126,7 +1127,7 @@ export class UnwrappedEventTile extends React.Component showRelative={this.context.timelineRenderingType === TimelineRenderingType.ThreadsList} showTwelveHour={this.props.isTwelveHour} ts={ts} - receivedTs={this.props.mxEvent.getUnsigned()["io.element.late_event"]?.received_at} + receivedTs={getLateEventInfo(this.props.mxEvent)?.received_at} /> ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3e14c2cbef..be3e9cd76f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3203,6 +3203,7 @@ "you": "You ended a voice broadcast" }, "io.element.widgets.layout": "%(senderName)s has updated the room layout", + "late_event_separator": "Originally sent %(dateTime)s", "load_error": { "no_permission": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "title": "Failed to load timeline position", diff --git a/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap b/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap index c47dfb377f..345e8ee507 100644 --- a/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -40,7 +40,7 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = ` >
  • @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