Open1

aa

GUIの変更

この章では主にGUIを変更して付け加えた機能について書きます。
追加した機能は主に

  • 文字の色を変える機能
  • 投稿を予約する機能

の二つです。

最終的にどうなったのか

こんな感じになりました。

詳細な機能は以下です。

  • 文字の色を変えるコマンド
  • 文字の色を変えるコマンドを押したら書き入れるボタン

文字の色を変えるのは<(color=red){Content}>というような構文をフロントエンド側で解析してContentの色を変える

  • スケジュール投稿用のコマンド
  • スケジュール用のコマンドを押したら書き入れるボタン

スケジュールは<schedule> 1h 1m 1s {Content}というようなコマンドをバックエンドで解析してDBに反映するのを指示されただけ遅らせる、またYY-MM-DD hh:mm:ssのような時間指定も解析可能

  • 押したらカラーパレットを開いて、そこから色を選択することができるボタン

最初の機能のアップデートです。

以上の機能を、実際にどう実装したか見ていきましょう。

文字の色を変えるコマンド

まず、このRocket.ChatというアプリはMarkdownを自動で解析して表示したり、太字や斜体にするための独自のコマンドを持っていました。
そこで、それらの構文を解析する場所に、新しく色を解析する機能を追加すればいいのではないかと考えました。
そこで見つけたのが、GazzodownTextというファイルです。
このファイルで定義されるのは、BEから送られてきたJSON形式で太字かどうか、斜体かどうかなどの情報を表されたメッセージを、解析して適切なHTML構文に直す関数に渡す関数です。
これに、色の構文が検出されればメッセージ部分を直接<span style={{color:"red"}}>{Content}</span>のように変更する機能も付ければいいと考えました。

TypeScript
const transformTextWithColor = (text: string): React.ReactNode[] => {
		const nodes: React.ReactNode[] = [];
		let currentIndex = 0;
	
		while (currentIndex < text.length) {
			const patternStart = text.indexOf('<(color==', currentIndex);
			if (patternStart === -1) {
				nodes.push(text.slice(currentIndex));
				break;
			}
	
			if (patternStart > currentIndex) {
				nodes.push(text.slice(currentIndex, patternStart));
			}
	
			const colorStart = patternStart + 9;
			const colorEnd = text.indexOf(')', colorStart);
			const contentEnd = text.indexOf('>', colorEnd);
	
			if (colorEnd === -1 || contentEnd === -1) {
				nodes.push(text.slice(currentIndex));
				break;
			}
	
			const color = text.slice(colorStart, colorEnd);
			const content = text.slice(colorEnd + 1, contentEnd);
	
			nodes.push(<span key={patternStart} style={{ color }}>{content}</span>);
	
			currentIndex = contentEnd + 1;
		}
	
		return nodes;
	};
	
	
	
    type Token = {
		type: string;
		value: string |  React.ReactNode | Token[];
	  };
	  
	  const transformTokens = (tokens: Token[]): Token[] => {
        return tokens.map((token) => {
			console.log(token)
            // PLAIN_TEXTの場合のみ変換を行う
            if (token.type === "PLAIN_TEXT") {
                return {
                    ...token,
                    value: transformTextWithColor(token.value as string),
                };
            }

            // その他のトークンタイプに関して、valueが配列(つまり、ネストされたトークンが存在する)場合
            // 再帰的にこの関数を適用する
            if (Array.isArray(token.value)) {
                return {
                    ...token,
                    value: transformTokens(token.value as Token[]),
                };
            }

            // 上記の条件に合致しないトークンはそのまま返す
            return token;
        });
    };

    const transformedChildren = React.cloneElement(children, {
        tokens: transformTokens(children.props.tokens),
    });

これが追加した部分です。バックエンドから送られてきたJSON形式のメッセージは、玉ねぎのように太字や斜体などの情報の皮で囲まれていて、最深部にPLAIN_TEXTがあります。

これに、色の構文が含まれるかを検出し、含まれるならReactnodeで囲んでコンポーネントにしてやって、前述のHTMLに直す関数に渡すことで、上手く行きました。

spanではなくReactnodeで囲むのは、Reactではテキスト中のHTML構文をエスケープするためです。

スケジュール構文を解析する機能の実装(バックエンド)

スケジューリングはバックエンド側でメッセージを解析して、スケジューリングをしめす構文があれば、DBに登録するのを遅らせるという方針で実装しました。

TypeScript
export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMessage, 'rid'>, previewUrls?: string[]) {
    if (message.msg.startsWith('<schedule>')) {
        const parts = message.msg.split(' ');
        const timeStr = parts.slice(1).find(p => p.includes('h') || p.includes('m') || p.includes('s'));
        const scheduledMessage = parts.slice(parts.indexOf(timeStr) + 1).join(' ');

        let delay;
		let whichsyntax=0;

        // Check if it's relative time like 2h, 30m, or 15s
        if (timeStr) {
            delay = convertToMilliseconds(timeStr);
			whichsyntax=1;
        } else {
            // Assuming format: YYYY-MM-DD HH:mm:ss for absolute time
            const scheduledDate = new Date(`${parts[1]} ${parts[2]}`);
            delay = scheduledDate.getTime() - new Date().getTime();
			whichsyntax=2;
        }

        if (delay > 0) {
            setTimeout(async () => {
				message.msg = scheduledMessage;
                await actualSendMessageLogic(uid, message, previewUrls);
            }, delay);
            return;
        }
    }
    await actualSendMessageLogic(uid, message, previewUrls);
}

function convertToMilliseconds(timeStr: string): number {
    let totalMilliseconds = 0;

    const hours = timeStr.match(/(\d+)h/);
    if (hours) {
        totalMilliseconds += parseInt(hours[1]) * 60 * 60 * 1000;
    }

    const minutes = timeStr.match(/(\d+)m/);
    if (minutes) {
        totalMilliseconds += parseInt(minutes[1]) * 60 * 1000;
    }

    const seconds = timeStr.match(/(\d+)s/);
    if (seconds) {
        totalMilliseconds += parseInt(seconds[1]) * 1000;
    }

    return totalMilliseconds;
}

actualSendMessageLogic関数(実際にDBに登録する関数)にメッセージを渡す前に、メッセージの構文を解析します。

そして、遅らせる時間、あるいは登録する絶対時間を検証して、その後actualSendMessageLoginに渡します。

この方法で予約投稿を実現できました。

押したら色の構文を追加するボタン,スケジュール構文を追加するボタンの実装

Rocket.Chatでは押したら**でかこんで太字にしたり、_ _で囲んで斜体にしたりするボタンがもともとありました。

なので、それを真似て押したら<color=""({もともとあったテキスト})>で囲むようにすればよいと考えました。

formattingButtonsというコンポーネントにこのような押したら選択されたテキストを加工する機能がありましたので、それにあたらしいボタンとして色の構文を追加するもの、スケジュール構文を追加するものを追加しました。

するとボタンを追加できました!

押したらカラーパレットを開いて、そこから色を選択することができるボタンの実装

一番難しかったのがこれでした。コマンドで色を指定するというのは非直観的なので、GUIで色を選択できるようにしたかったのですが、UIを整えることができませんでした。

mantineというreactのライブラリのカラーパレットを使おうとしたのですが、謎のエラーでmantineをインストールできなかったのが敗因でした。

なので、手作りの16色並べただけのカラーパレットを作りました。

TypeScript
import React, { useState, useCallback } from 'react';
import { ColorPicker, Text, Stack } from '@mantine/core';
import { ColorPickerContext } from '../contexts/ColorPickerContext';

export const ColorPickerProvider: React.FC = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedColor, setSelectedColor] = useState<string>('rgba(47, 119, 150, 0.7)');
  const [recentColors, setRecentColors] = useState<string[]>([]); // 追加: recentColorsのステート

  const open = useCallback(() => {
    setIsOpen(true);
  }, []);

  const close = useCallback(() => {
    setIsOpen(false);
  }, []);

  return (
    <ColorPickerContext.Provider value={{
      open,
      isOpen,
      close,
      selectedColor,
      setSelectedColor, // selectColor から setSelectedColor への名前変更
      recentColors, // 追加: recentColors
      setRecentColors, // 追加: setRecentColors
    }}>
      {isOpen && (
        <Stack align="center">
          <ColorPicker format="rgba" value={selectedColor} onChange={setSelectedColor} />
          <Text>{selectedColor}</Text>
        </Stack>
      )}
      {children}
    </ColorPickerContext.Provider>
  );
};

このコンポーネントが押したら表示されるボタンを作り、色をえらぶとその色でコマンドが入力されるものができました。

ただ、絵文字を選ぶコンポーネント(既存)と違って
、overlapして表示されなかったので、それらの表示場所を司る場所の編集ができなかったのが今後の課題です。