From f3a16be0dd93617bebc5a455d0b50ec2ad0548b0 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Wed, 20 Jul 2022 20:42:23 -0700 Subject: [PATCH] Add user detail API + modal. Closes #2002 --- .github/workflows/build-next.yml | 2 +- .github/workflows/build-storybook.yml | 4 +- .github/workflows/chromatic.yml | 2 +- .github/workflows/javascript-formatting.yml | 2 +- controllers/moderation/moderation.go | 71 +++++++ core/chat/persistence.go | 25 +++ db/db.go | 2 +- db/models.go | 15 +- db/query.sql | 3 + db/query.sql.go | 41 +++- db/schema.sql | 18 ++ router/router.go | 4 + web/.storybook/main.js | 1 + web/.storybook/preview.js | 3 + .../ChatModerationActionMenu.tsx | 42 +---- .../ChatModerationDetailsModal.module.scss | 7 + .../ChatModerationDetailsModal.tsx | 178 ++++++++++++++++-- web/services/chat-service.ts | 1 + web/services/moderation-service.ts | 38 ++++ .../ChatModerationActionMenu.stories.tsx | 77 +++++++- .../ChatModerationDetailsModal.stories.tsx | 67 ++++++- 21 files changed, 543 insertions(+), 60 deletions(-) create mode 100644 controllers/moderation/moderation.go create mode 100644 web/services/moderation-service.ts diff --git a/.github/workflows/build-next.yml b/.github/workflows/build-next.yml index 383961321..6e8488b97 100644 --- a/.github/workflows/build-next.yml +++ b/.github/workflows/build-next.yml @@ -13,4 +13,4 @@ jobs: uses: actions/checkout@v2 - name: Install dependencies - run: cd web && npm install && npm run build + run: cd web && npm install --include=dev --force && npm run build diff --git a/.github/workflows/build-storybook.yml b/.github/workflows/build-storybook.yml index 3d15a4d3a..11a03fb61 100644 --- a/.github/workflows/build-storybook.yml +++ b/.github/workflows/build-storybook.yml @@ -1,7 +1,7 @@ name: Build and Deploy Components+Style Guide on: push: - paths: ["web/stories/**", "web/components/**"] # Trigger the action only when files change in the folders defined here + paths: ['web/stories/**', 'web/components/**'] # Trigger the action only when files change in the folders defined here jobs: build-and-deploy: runs-on: ubuntu-latest @@ -14,7 +14,7 @@ jobs: - name: Install and Build run: | # Install npm packages and build the Storybook files cd web - npm install --include-dev + npm install --include-dev --force npm run build-storybook -- -o ../docs/components - name: Dispatch event to web site diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index bdea52c0b..d60838bfb 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v1 - name: Install dependencies - run: npm install --include=dev + run: npm install --include=dev --force # 👇 Adds Chromatic as a step in the workflow - name: Publish to Chromatic uses: chromaui/action@v1 diff --git a/.github/workflows/javascript-formatting.yml b/.github/workflows/javascript-formatting.yml index 3587ee18a..dc1f8d5f3 100644 --- a/.github/workflows/javascript-formatting.yml +++ b/.github/workflows/javascript-formatting.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v2 - name: Install Dependencies - run: npm install + run: npm install --force - name: Lint run: npm run lint diff --git a/controllers/moderation/moderation.go b/controllers/moderation/moderation.go new file mode 100644 index 000000000..d799e4608 --- /dev/null +++ b/controllers/moderation/moderation.go @@ -0,0 +1,71 @@ +package moderation + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/chat" + "github.com/owncast/owncast/core/chat/events" + "github.com/owncast/owncast/core/user" + log "github.com/sirupsen/logrus" +) + +// GetUserDetails returns the details of a chat user for moderators. +func GetUserDetails(w http.ResponseWriter, r *http.Request) { + type connectedClient struct { + MessageCount int `json:"messageCount"` + UserAgent string `json:"userAgent"` + ConnectedAt time.Time `json:"connectedAt"` + Geo string `json:"geo,omitempty"` + } + + type response struct { + User *user.User `json:"user"` + ConnectedClients []connectedClient `json:"connectedClients"` + Messages []events.UserMessageEvent `json:"messages"` + } + + pathComponents := strings.Split(r.URL.Path, "/") + uid := pathComponents[len(pathComponents)-1] + + u := user.GetUserByID(uid) + + if u == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + c, _ := chat.GetClientsForUser(uid) + clients := make([]connectedClient, len(c)) + for i, c := range c { + client := connectedClient{ + MessageCount: c.MessageCount, + UserAgent: c.UserAgent, + ConnectedAt: c.ConnectedAt, + } + if c.Geo != nil { + client.Geo = c.Geo.CountryCode + } + + clients[i] = client + } + + messages, err := chat.GetMessagesFromUser(uid) + if err != nil { + log.Errorln(err) + } + + res := response{ + User: u, + ConnectedClients: clients, + Messages: messages, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(res); err != nil { + controllers.InternalErrorHandler(w, err) + } +} diff --git a/core/chat/persistence.go b/core/chat/persistence.go index d50bb06c6..4e93b7924 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -1,6 +1,8 @@ package chat import ( + "context" + "database/sql" "fmt" "strings" "time" @@ -300,6 +302,29 @@ func GetChatHistory() []interface{} { return m } +// GetMessagesFromUser returns chat messages that were sent by a specific user. +func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) { + query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true}) + if err != nil { + return nil, err + } + + results := make([]events.UserMessageEvent, len(query)) + for i, row := range query { + results[i] = events.UserMessageEvent{ + Event: events.Event{ + Timestamp: row.Timestamp.Time, + ID: row.ID, + }, + MessageEvent: events.MessageEvent{ + Body: row.Body.String, + }, + } + } + + return results, nil +} + // SetMessageVisibilityForUserID will bulk change the visibility of messages for a user // and then send out visibility changed events to chat clients. func SetMessageVisibilityForUserID(userID string, visible bool) error { diff --git a/db/db.go b/db/db.go index cbb8c9a5d..f30b89ec0 100644 --- a/db/db.go +++ b/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.13.0 +// sqlc v1.14.0 package db diff --git a/db/models.go b/db/models.go index 91d932907..369309471 100644 --- a/db/models.go +++ b/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.13.0 +// sqlc v1.14.0 package db @@ -52,6 +52,19 @@ type IpBan struct { CreatedAt sql.NullTime } +type Message struct { + ID string + UserID sql.NullString + Body sql.NullString + EventType sql.NullString + HiddenAt sql.NullTime + Timestamp sql.NullTime + Title sql.NullString + Subtitle sql.NullString + Image sql.NullString + Link sql.NullString +} + type Notification struct { ID int32 Channel string diff --git a/db/query.sql b/db/query.sql index 6e386f802..b1fce3e15 100644 --- a/db/query.sql +++ b/db/query.sql @@ -99,6 +99,9 @@ UPDATE user_access_tokens SET user_id = $1 WHERE token = $2; -- name: SetUserAsAuthenticated :exec UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1; +-- name: GetMessagesFromUser :many +SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC; + -- name: IsDisplayNameAvailable :one SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL; diff --git a/db/query.sql.go b/db/query.sql.go index 00975e9eb..19996759d 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.13.0 +// sqlc v1.14.0 // source: query.sql package db @@ -412,6 +412,45 @@ func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) { return count, err } +const getMessagesFromUser = `-- name: GetMessagesFromUser :many +SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC +` + +type GetMessagesFromUserRow struct { + ID string + Body sql.NullString + HiddenAt sql.NullTime + Timestamp sql.NullTime +} + +func (q *Queries) GetMessagesFromUser(ctx context.Context, userID sql.NullString) ([]GetMessagesFromUserRow, error) { + rows, err := q.db.QueryContext(ctx, getMessagesFromUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMessagesFromUserRow + for rows.Next() { + var i GetMessagesFromUserRow + if err := rows.Scan( + &i.ID, + &i.Body, + &i.HiddenAt, + &i.Timestamp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getNotificationDestinationsForChannel = `-- name: GetNotificationDestinationsForChannel :many SELECT destination FROM notifications WHERE channel = $1 ` diff --git a/db/schema.sql b/db/schema.sql index 33976add6..644813648 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -79,3 +79,21 @@ CREATE TABLE IF NOT EXISTS auth ( "timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL ); CREATE INDEX auth_token ON auth (token); + +CREATE TABLE IF NOT EXISTS messages ( + "id" string NOT NULL, + "user_id" TEXT, + "body" TEXT, + "eventType" TEXT, + "hidden_at" DATE, + "timestamp" DATE, + "title" TEXT, + "subtitle" TEXT, + "image" TEXT, + "link" TEXT, + PRIMARY KEY (id) + );CREATE INDEX index ON messages (id, user_id, hidden_at, timestamp); + CREATE INDEX id ON messages (id); + CREATE INDEX user_id ON messages (user_id); + CREATE INDEX hidden_at ON messages (hidden_at); + CREATE INDEX timestamp ON messages (timestamp); diff --git a/router/router.go b/router/router.go index 9e52f7e79..bd0cab659 100644 --- a/router/router.go +++ b/router/router.go @@ -15,6 +15,7 @@ import ( "github.com/owncast/owncast/controllers/admin" fediverseauth "github.com/owncast/owncast/controllers/auth/fediverse" "github.com/owncast/owncast/controllers/auth/indieauth" + "github.com/owncast/owncast/controllers/moderation" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user" @@ -323,6 +324,9 @@ func Start() error { // Enable/disable a user http.HandleFunc("/api/chat/users/setenabled", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateUserEnabled)) + // Get a user's details + http.HandleFunc("/api/moderation/chat/user/", middleware.RequireUserModerationScopeAccesstoken(moderation.GetUserDetails)) + // Configure Federation features // enable/disable federation features diff --git a/web/.storybook/main.js b/web/.storybook/main.js index 11adedf7a..0543917b4 100644 --- a/web/.storybook/main.js +++ b/web/.storybook/main.js @@ -17,6 +17,7 @@ module.exports = { 'storybook-addon-designs', 'storybook-dark-mode', 'addon-screen-reader', + 'storybook-addon-fetch-mock', ], webpackFinal: async (config, { configType }) => { // @see https://github.com/storybookjs/storybook/issues/9070 diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js index c1d5f4eb4..b67a3217d 100644 --- a/web/.storybook/preview.js +++ b/web/.storybook/preview.js @@ -6,6 +6,9 @@ import { themes } from '@storybook/theming'; import { DocsContainer } from './storybook-theme'; export const parameters = { + fetchMock: { + mocks: [], + }, actions: { argTypesRegex: '^on[A-Z].*' }, docs: { container: DocsContainer, diff --git a/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx b/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx index 6da4799b4..2de8d66fb 100644 --- a/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx +++ b/web/components/chat/ChatModerationActionMenu/ChatModerationActionMenu.tsx @@ -4,16 +4,14 @@ import { EyeInvisibleOutlined, SmallDashOutlined, } from '@ant-design/icons'; -import { Dropdown, Menu, MenuProps, Space, Modal } from 'antd'; +import { Dropdown, Menu, MenuProps, Space, Modal, message } from 'antd'; import { useState } from 'react'; import ChatModerationDetailsModal from './ChatModerationDetailsModal'; import s from './ChatModerationActionMenu.module.scss'; +import ChatModeration from '../../../services/moderation-service'; const { confirm } = Modal; -const HIDE_MESSAGE_ENDPOINT = `/api/chat/messagevisibility`; -const BAN_USER_ENDPOINT = `/api/chat/users/setenabled`; - /* eslint-disable @typescript-eslint/no-unused-vars */ interface Props { accessToken: string; @@ -27,42 +25,20 @@ export default function ChatModerationActionMenu(props: Props) { const [showUserDetailsModal, setShowUserDetailsModal] = useState(false); const handleBanUser = async () => { - const url = new URL(BAN_USER_ENDPOINT); - url.searchParams.append('accessToken', accessToken); - const hideMessageUrl = url.toString(); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userID }), - }; - try { - await fetch(hideMessageUrl, options); + await ChatModeration.banUser(userID, accessToken); } catch (e) { console.error(e); + message.error(e); } }; const handleHideMessage = async () => { - const url = new URL(HIDE_MESSAGE_ENDPOINT); - url.searchParams.append('accessToken', accessToken); - const hideMessageUrl = url.toString(); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ idArray: [messageID] }), - }; - try { - await fetch(hideMessageUrl, options); + await ChatModeration.removeMessage(messageID, accessToken); } catch (e) { console.error(e); + message.error(e); } }; @@ -141,12 +117,14 @@ export default function ChatModerationActionMenu(props: Props) { { setShowUserDetailsModal(false); }} > - + ); diff --git a/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.module.scss b/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.module.scss index 46db7b394..4ac5e234e 100644 --- a/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.module.scss +++ b/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.module.scss @@ -8,3 +8,10 @@ border: 1px solid #ccc; border-radius: 5px; } + +.colorBlock { + width: 50%; + height: 20px; + border: 1px solid #000; + text-align: center; +} diff --git a/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.tsx b/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.tsx index 79e850bf7..98fb5ae5c 100644 --- a/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.tsx +++ b/web/components/chat/ChatModerationActionMenu/ChatModerationDetailsModal.tsx @@ -1,27 +1,179 @@ -import { Col, Row } from 'antd'; +import { Button, Col, Row, Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import ChatModeration from '../../../services/moderation-service'; import s from './ChatModerationDetailsModal.module.scss'; -/* eslint-disable @typescript-eslint/no-unused-vars */ interface Props { - // userID: string; + userId: string; + accessToken: string; } +export interface UserDetails { + user: User; + connectedClients: Client[]; + messages: Message[]; +} + +export interface Client { + messageCount: number; + userAgent: string; + connectedAt: Date; + geo: string; +} + +export interface Message { + id: string; + timestamp: Date; + user: null; + body: string; +} + +export interface User { + id: string; + displayName: string; + displayColor: number; + createdAt: Date; + previousNames: string[]; + scopes: string[]; + isBot: boolean; + authenticated: boolean; +} + +const removeMessage = async (messageId: string, accessToken: string) => { + try { + ChatModeration.removeMessage(messageId, accessToken); + } catch (e) { + console.error(e); + } +}; + +const ValueRow = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +); + +const ChatMessageRow = ({ + id, + body, + accessToken, +}: { + id: string; + body: string; + accessToken: string; +}) => ( + + {body} + + + + +); + +const ConnectedClient = ({ client }: { client: Client }) => { + const { messageCount, userAgent, connectedAt, geo } = client; + + return ( +
+ + + + +
+ ); +}; + +// eslint-disable-next-line react/prop-types +const UserColorBlock = ({ color }) => { + const bg = `var(--theme-user-colors-${color})`; + return ( + + Color + +
+ {color} +
+ +
+ ); +}; + export default function ChatModerationDetailsModal(props: Props) { + const { userId, accessToken } = props; + const [userDetails, setUserDetails] = useState(null); + const [loading, setLoading] = useState(true); + + const getDetails = async () => { + try { + const response = await (await fetch(`/api/moderation/chat/user/${userId}`)).json(); + setUserDetails(response); + setLoading(false); + } catch (e) { + console.error(e); + } + }; + + useEffect(() => { + getDetails(); + }, []); + + if (!userDetails) { + return null; + } + + const { user, connectedClients, messages } = userDetails; + const { displayName, displayColor, createdAt, previousNames, scopes, isBot, authenticated } = + user; + return (
- - User created - xxxx - + +

{displayName}

+ + {scopes.map(scope => ( + {scope} + ))} + {authenticated && Authenticated} + {isBot && Bot} + - - Previous names - xxxx - + -

Recent Chat Messages

+ + -
+
+ +

Currently Connected

+ {connectedClients.length > 0 && ( + + {connectedClients.map(client => ( + + + + ))} + + )} + +
+ {messages.length > 0 && ( +
+

Recent Chat Messages

+ +
+ {messages.map(message => ( + + ))} +
+
+ )} +
); } diff --git a/web/services/chat-service.ts b/web/services/chat-service.ts index da4bb7fa3..c84cab452 100644 --- a/web/services/chat-service.ts +++ b/web/services/chat-service.ts @@ -1,5 +1,6 @@ import { ChatMessage } from '../interfaces/chat-message.model'; import { getUnauthedData } from '../utils/apis'; + const ENDPOINT = `/api/chat`; const URL_CHAT_REGISTRATION = `/api/chat/register`; diff --git a/web/services/moderation-service.ts b/web/services/moderation-service.ts new file mode 100644 index 000000000..2c0f961c4 --- /dev/null +++ b/web/services/moderation-service.ts @@ -0,0 +1,38 @@ +const HIDE_MESSAGE_ENDPOINT = `/api/chat/messagevisibility`; +const BAN_USER_ENDPOINT = `/api/chat/users/setenabled`; + +class ChatModerationService { + public static async removeMessage(id: string, accessToken: string): Promise { + const url = new URL(HIDE_MESSAGE_ENDPOINT, window.location.toString()); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ idArray: [id] }), + }; + + await fetch(hideMessageUrl, options); + } + + public static async banUser(id: string, accessToken: string): Promise { + const url = new URL(BAN_USER_ENDPOINT, window.location.toString()); + url.searchParams.append('accessToken', accessToken); + const hideMessageUrl = url.toString(); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id }), + }; + + await fetch(hideMessageUrl, options); + } +} + +export default ChatModerationService; diff --git a/web/stories/ChatModerationActionMenu.stories.tsx b/web/stories/ChatModerationActionMenu.stories.tsx index f9825bb1b..4fdfedcf9 100644 --- a/web/stories/ChatModerationActionMenu.stories.tsx +++ b/web/stories/ChatModerationActionMenu.stories.tsx @@ -1,11 +1,74 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; import ChatModerationActionMenu from '../components/chat/ChatModerationActionMenu/ChatModerationActionMenu'; +const mocks = { + mocks: [ + { + // The "matcher" determines if this + // mock should respond to the current + // call to fetch(). + matcher: { + name: 'response', + url: 'glob:/api/moderation/chat/user/*', + }, + // If the "matcher" matches the current + // fetch() call, the fetch response is + // built using this "response". + response: { + status: 200, + body: { + user: { + id: 'hjFPU967R', + displayName: 'focused-snyder', + displayColor: 2, + createdAt: '2022-07-12T13:08:31.406505322-07:00', + previousNames: ['focused-snyder'], + scopes: ['MODERATOR'], + isBot: false, + authenticated: false, + }, + connectedClients: [ + { + messageCount: 3, + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', + connectedAt: '2022-07-20T16:45:07.796685618-07:00', + geo: 'N/A', + }, + ], + messages: [ + { + id: 'bQp8UJR4R', + timestamp: '2022-07-20T16:53:41.938083228-07:00', + user: null, + body: 'test message 3', + }, + { + id: 'ubK88Jg4R', + timestamp: '2022-07-20T16:53:39.675531279-07:00', + user: null, + body: 'test message 2', + }, + { + id: '20v8UJRVR', + timestamp: '2022-07-20T16:53:37.551084121-07:00', + user: null, + body: 'test message 1', + }, + ], + }, + }, + }, + ], +}; + export default { title: 'owncast/Chat/Moderation menu', component: ChatModerationActionMenu, parameters: { + fetchMock: mocks, docs: { description: { component: `This should be a popup that is activated from a user's chat message. It should have actions to: @@ -20,12 +83,14 @@ export default { // eslint-disable-next-line @typescript-eslint/no-unused-vars const Template: ComponentStory = args => ( - + + + ); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/stories/ChatModerationDetailsModal.stories.tsx b/web/stories/ChatModerationDetailsModal.stories.tsx index c0d3cff1e..8eec402f5 100644 --- a/web/stories/ChatModerationDetailsModal.stories.tsx +++ b/web/stories/ChatModerationDetailsModal.stories.tsx @@ -1,11 +1,74 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; import ChatModerationDetailsModal from '../components/chat/ChatModerationActionMenu/ChatModerationDetailsModal'; +const mocks = { + mocks: [ + { + // The "matcher" determines if this + // mock should respond to the current + // call to fetch(). + matcher: { + name: 'response', + url: 'glob:/api/moderation/chat/user/*', + }, + // If the "matcher" matches the current + // fetch() call, the fetch response is + // built using this "response". + response: { + status: 200, + body: { + user: { + id: 'hjFPU967R', + displayName: 'focused-snyder', + displayColor: 2, + createdAt: '2022-07-12T13:08:31.406505322-07:00', + previousNames: ['focused-snyder'], + scopes: ['MODERATOR'], + isBot: false, + authenticated: false, + }, + connectedClients: [ + { + messageCount: 3, + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', + connectedAt: '2022-07-20T16:45:07.796685618-07:00', + geo: 'N/A', + }, + ], + messages: [ + { + id: 'bQp8UJR4R', + timestamp: '2022-07-20T16:53:41.938083228-07:00', + user: null, + body: 'test message 3', + }, + { + id: 'ubK88Jg4R', + timestamp: '2022-07-20T16:53:39.675531279-07:00', + user: null, + body: 'test message 2', + }, + { + id: '20v8UJRVR', + timestamp: '2022-07-20T16:53:37.551084121-07:00', + user: null, + body: 'test message 1', + }, + ], + }, + }, + }, + ], +}; + export default { title: 'owncast/Chat/Moderation modal', component: ChatModerationDetailsModal, parameters: { + fetchMock: mocks, docs: { description: { component: `This should be a modal that gives the moderator more details about the user such as: @@ -20,7 +83,9 @@ export default { // eslint-disable-next-line @typescript-eslint/no-unused-vars const Template: ComponentStory = args => ( - + + + ); // eslint-disable-next-line @typescript-eslint/no-unused-vars